Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ OPENCHARGEMAP_API_KEY=

# ── Speed cameras (OpenStreetMap) ─────────────────────────────────────────────
# Speed camera positions as mapped in OpenStreetMap, shown as a map layer that is
# off by default and switched on per browser under the map's layer panel. Tagged
# off by default and switched on per account under the map's layer panel. Tagged
# positions only: GarageStack is a dashboard, not a navigation app, and it never
# warns about a camera ahead while driving.
# Set to false to remove the layer from the deployment entirely, which is what to
Expand All @@ -196,7 +196,7 @@ OPENCHARGEMAP_API_KEY=
# street the car is parked in. No API key is required. Answers are cached in the
# database for 90 days, so the same spot is only ever looked up once.
# Set to false to keep coordinates off a third-party service; the map then falls
# back to showing dates in the trip list. Each browser can also switch place names
# back to showing dates in the trip list. Each account can also switch place names
# off on its own under Settings > Map, which is on by default.
# GEOCODING__ENABLED=true
# Point this at your own Nominatim to avoid the public instance entirely.
Expand All @@ -208,7 +208,7 @@ OPENCHARGEMAP_API_KEY=
# required. Snapped trips are cached in the database for 30 days, so a trip is only
# ever matched once.
# Set to false to keep trip geometry off a third-party service; trips then keep
# being drawn from their raw fixes. Each browser can also switch snapping off on
# being drawn from their raw fixes. Each account can also switch snapping off on
# its own under the map's filter panel, which is on by default.
# MAPMATCHING__ENABLED=true
# Point this at your own Valhalla to avoid the public instance entirely.
Expand Down
1 change: 1 addition & 0 deletions .github/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ This project follows a coordinated vulnerability disclosure model. Good-faith se
### Database

- The bundled deployments use one PostgreSQL role, owned by the application, for both the API and the Worker. There is no split into per-service roles; on an external server you can create a dedicated role with access to the `garagestack` database only
- User settings are stored per account under a SHA-256 hash of the sign-in method and subject, so the settings table holds no user name or email address. The server accepts only the known sections, plain identifier keys and 16 KB per section
- Personal identifiers are kept out of the rotating log files: the MG account email and VIN are redacted from MQTT topics before logging, VINs are shortened to their last four characters, and failed login attempts log only the client IP. Location data is stored in the database and never logged

### Dependencies
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,14 @@ The statistics, trip log, maintenance list and light theme are in the [screensho
- **Fuel stations** -- Overlay nearby petrol and diesel stations on the map (HEV and PHEV only; not shown for BEV). Sourced from OpenStreetMap via the Overpass API -- no API key required. POI data is cached in the database for 7 days and pre-populated by the Worker for a 100 km radius around the car's last known position so the overlay is instant on first view.
- **Motorway service areas** -- Overlay motorway service areas and rest stops on the map (all vehicle types). Same DB-backed cache and Worker pre-cache as fuel stations; useful for BEV drivers who often find fast chargers at service areas.
- **Speed cameras** -- Overlay the speed cameras mapped in OpenStreetMap (`highway=speed_camera`), with the limit each one enforces and the kind of camera it is where OSM records them. Same DB-backed cache and Worker pre-cache as fuel stations, off until you switch it on in the map's layer panel, and removable from the deployment with `SPEEDCAMERAS__ENABLED=false` -- see the note on jurisdictions in the Map overlays section.
- **Place names** -- Trips are listed by where they went ("Zwolle to Deventer") instead of by date alone, and the dashboard's location card names the street the car is parked in. Sourced from OpenStreetMap via [Nominatim](https://nominatim.openstreetmap.org) -- no API key required, answers are cached in the database for 90 days, and the whole feature can be switched off per browser under Settings > Map, or for the deployment with `GEOCODING__ENABLED=false`.
- **Place names** -- Trips are listed by where they went ("Zwolle to Deventer") instead of by date alone, and the dashboard's location card names the street the car is parked in. Sourced from OpenStreetMap via [Nominatim](https://nominatim.openstreetmap.org) -- no API key required, answers are cached in the database for 90 days, and the whole feature can be switched off under Settings > Map, or for the deployment with `GEOCODING__ENABLED=false`.
- **Snapped trip lines** -- A selected trip is drawn along the roads it was driven on rather than in straight lines between GPS fixes, which also gives a truer distance than the fixes alone. Matched against OpenStreetMap by [Valhalla](https://valhalla1.openstreetmap.de) -- no API key required, snapped trips are cached in the database for 30 days, and it can be switched off in the map's filter panel or for the deployment with `MAPMATCHING__ENABLED=false`.
- **Speed limits** -- The selected trip can be coloured against the limits signposted along it, green within and red above, with how far over it went and over how much of the trip a limit was known. Read from OpenStreetMap's `maxspeed` tags by the same match that snapped the trip, so it costs no extra request and needs no configuration.
- **Themed vector basemap** -- Every map is drawn from OpenStreetMap vector tiles by MapLibre GL, in a dark or light style that follows the interface theme (or in full colour, if you prefer) and with labels in the interface language. Served by [OpenFreeMap](https://openfreemap.org) without an API key, point it at your own tile server if you prefer, and it falls back to raster tiles where WebGL is unavailable.
- **Single sign-on** -- Sign in through your own identity provider (Authentik, Authelia, Keycloak, Pocket ID, Google, and anything else speaking OpenID Connect), with optional auto-login and group or email based access restrictions. A built-in username/password login remains available for installs without a provider. See [`AUTHENTICATION.md`](documentation/AUTHENTICATION.md).
- **Multi-language support** -- Interface available in English and Dutch, with locale resolved from query string, cookie, or browser preference.
- **Units** -- Kilometres or miles, Celsius or Fahrenheit, bar, psi or kPa for tyres, and L/100 km or miles per UK or US gallon for fuel, chosen per browser under Settings > Units. Everything is stored in metric and converted only for display, so switching back and forth loses nothing; the trip log's CSV export follows the chosen distance unit. The homepage widget, Home Assistant and the `TYRE_PRESSURE_*_BAR` thresholds stay metric.
- **Units** -- Kilometres or miles, Celsius or Fahrenheit, bar, psi or kPa for tyres, and L/100 km or miles per UK or US gallon for fuel, chosen under Settings > Units. Everything is stored in metric and converted only for display, so switching back and forth loses nothing; the trip log's CSV export follows the chosen distance unit. The homepage widget, Home Assistant and the `TYRE_PRESSURE_*_BAR` thresholds stay metric.
- **Settings follow you** -- Theme, language, units, the dashboard layout and the map's layers and filters are saved to your account, so every phone, tablet and computer you sign in on looks the same. Each browser keeps a copy too, so pages open instantly and the login page keeps your theme and language. After upgrading, the first device to sign in brings its settings along.
- **Self-hosted** -- Runs entirely on your own infrastructure via Docker (all-in-one container or Docker Compose). No cloud account or subscription required beyond the SAIC iSmart API.

### Dashboard cards
Expand Down Expand Up @@ -420,7 +421,7 @@ Sourced from OpenStreetMap (`highway=services`) via the Overpass API -- no API k
Sourced from OpenStreetMap (`highway=speed_camera`) via the Overpass API -- no API key required.

- Shows the cameras OSM holds a position for, all vehicle types. A marker's popup names the limit the camera enforces (`maxspeed`), the kind of camera (fixed, mobile site, average speed check, red light) and its operator, as far as the node is tagged for them. Coverage and accuracy are whatever OSM's mappers have entered.
- Off by default. **The map's layer panel > Speed cameras** switches it on per browser.
- Off by default. **The map's layer panel > Speed cameras** switches it on.
- Positions on a map, not warnings: GarageStack is a dashboard of the car's state, and nothing here alerts you to a camera ahead while driving.
- Several countries restrict pointing a driver at camera positions, France and Germany among them. Set `SPEEDCAMERAS__ENABLED=false` to remove the layer from the deployment: the toggle then disappears from the layer panel, the endpoint serves nothing, and the Worker fetches no camera data either.
- Same DB-backed cache and Worker pre-population as fuel stations. The same Overpass slowness applies for uncached tiles outside the pre-populated radius -- see the note in the Fuel stations section above.
Expand All @@ -434,7 +435,7 @@ Sourced from OpenStreetMap via [Nominatim](https://nominatim.openstreetmap.org)
- Answers are cached in PostgreSQL for 90 days, keyed by a coordinate grid and by language, so the same parking spot is looked up once. "Nothing mapped here" is cached for a day, since OSM coverage grows.
- The public Nominatim instance allows about one request per second, so a request resolves at most three new coordinates and the browser asks again for the rest; a cold trip list fills in over a few seconds and is instant from then on.
- Names follow the interface language, so switching between English and Dutch looks the places up again in that language.
- **Settings > Map > Show place names** turns the whole thing off per browser (it is on by default). Off, trips are listed by date, the location card shows no address, and no coordinates leave the browser.
- **Settings > Map > Show place names** turns the whole thing off (it is on by default). Off, trips are listed by date, the location card shows no address, and no coordinates leave the browser.
- Set `GEOCODING__ENABLED=false` to switch it off for the whole deployment instead, or point `GEOCODING__BASEURL` at your own Nominatim instance.

### Snapped trip lines (map matching)
Expand All @@ -447,7 +448,7 @@ Telemetry arrives as GPS fixes tens of seconds apart, so a trip drawn straight f
- A trip with a hole in its telemetry (the gateway stopped publishing for a while) is matched in parts and stitched back together, with the hole left as the straight jump it always was. Valhalla refuses a trace containing a jump wider than 10 km, which is exactly what such a hole looks like.
- A match whose length no longer resembles the fixes it came from is discarded and the raw line kept: with sparse fixes a router can return a plausible route that is not the one taken.
- Snapped trips are cached in PostgreSQL for 30 days, keyed by a hash of the fixes, so a trip is matched once however often it is selected. "These fixes snap to nothing" is cached for a day.
- **The map's filter panel > Snap to roads** turns it off per browser (it is on by default), and trips are then drawn from their raw fixes as before.
- **The map's filter panel > Snap to roads** turns it off (it is on by default), and trips are then drawn from their raw fixes as before.
- Set `MAPMATCHING__ENABLED=false` to switch it off for the whole deployment instead, or point `MAPMATCHING__BASEURL` at your own Valhalla instance.

### Speed limits
Expand Down
13 changes: 11 additions & 2 deletions documentation/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,15 @@ Both `/api/auth/oidc/login` and the callback answer failures with a redirect to

The callback lives at `/api/auth/oidc/callback` rather than the handler's default `/signin-oidc` because nginx only proxies `/api/` and `/hubs/` to the API. Operator-facing documentation is in [`AUTHENTICATION.md`](AUTHENTICATION.md); `AuthenticationFlowTests` drives the whole round-trip against a fake provider.

## Settings

A user's settings (theme, language, units, the dashboard layout, the map's layers and filters) belong to the account they sign in with and follow it to every device. The browser owns what they mean; the server only keeps them.

- **Stores.** `settingsUi`, `settingsDashboard` and `settingsMap` each hold one section. `persistSettings` in `settingsShared.ts` keeps a section in localStorage, which is what a page starts with and all there is while signed out, and hands it to `settingsSync`.
- **Sync.** `settingsSync` reads `GET /api/settings` alongside the router guard's session check, and the guard lets no protected view mount until it has been taken in (the dashboard arranges its cards on a first visit, and must not do so over a layout saved elsewhere). From then on each local write sends only the keys that differ from what the account was last known to hold (`PATCH /api/settings/{section}`). A key changed on this page wins over the account's copy; every other key takes the account's. The page reads again when it comes back into view (at most every 30 seconds) or back online, and an account with no copy of a section yet is filled from this browser's own.
- **Server.** `UserSettings` holds one row per account per section: a JSON object the server does not interpret beyond the fixed section list, key names, and a 16 KB cap. `UserSettingsRepository.MergeAsync` merges a save key by key, with a version column and a short randomised retry, so devices saving at the same moment keep each other's keys. Accounts are keyed by `SessionPrincipal.ResolveAccountKey`, a SHA-256 of the sign-in method and subject: no user name or email address is stored, and a password account and a provider account of the same name stay apart.
- **A new setting** needs no backend change: a field in its store's interface, defaults and parse function travels with the section. The parse function is what reads the account's copy too, so it must fall back on values it does not know, which an older or newer build on another device may have saved.

## Database

Code-first EF Core migrations live in `src/GarageStack.Data/Migrations/`. `Program.cs` runs `db.Database.MigrateAsync()` on Api startup in normal operation; in `DEMO_MODE` it calls `DemoSeeder.SeedAsync()` instead, which creates the in-memory schema and seeds fake data, bypassing a real Postgres server entirely.
Expand Down Expand Up @@ -218,8 +227,8 @@ REST calls go through `frontend/src/services/` - `apiCore.ts` centralizes the `f

The vehicle store owns `activeVehicle`/`activeVin` (the one car this instance follows) and `effectiveVehicleType` (the user's manual override, else the drivetrain the API detected from the gateway's `hw_version` series code and the car's `BType` configuration code, in `VehicleTypeHelper`); views read those rather than indexing into the vehicle list or repeating the override logic. The TypeScript interfaces in `services/` mirror the API's DTOs by hand; the history endpoint returns `TelemetryHistoryPoint` (the chart fields only), not full snapshots.

The API speaks metric only (km, km/h, bar, °C, litres, L/100 km), and so does the database. A
browser's unit choice lives in the UI settings store and is applied at the last moment by
The API speaks metric only (km, km/h, bar, °C, litres, L/100 km), and so does the database. The
chosen units live in the UI settings store and are applied at the last moment by
`UnitFormatter` in `utils/units.ts`, reached through the `useUnits` composable: a value on screen
goes through `units.format(quantity, metricValue)` (or `measure` when the number and its unit are
styled apart), never through a hand-written `km` or `bar`. A distance typed into a form goes back
Expand Down
6 changes: 6 additions & 0 deletions documentation/AUTHENTICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,12 @@ The usual reason to want it: your identity provider runs on the same machine as

Set `AUTH_COOKIE_SECURE=true` whenever GarageStack is reachable over HTTPS. It defaults to `false` so plain-HTTP LAN installs work out of the box.

### Settings belong to the account

Theme, language, units, the dashboard layout and the map settings are saved per account, identified by the sign-in method and the account's subject (the provider's `sub`, or the password login's user name). Everyone signing in through your provider gets their own settings, and a password account never shares settings with a provider account, even one of the same name.

Switching from the password login to a provider, or changing `AUTH_USERNAME`, therefore starts a new, empty account. Nothing is lost: the first device to sign in to it fills it from the settings that browser already had.

---

## Upgrading from the previous login
Expand Down
13 changes: 12 additions & 1 deletion frontend/src/components/settings/VehicleTypeSetting.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,23 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Multiselect from '@vueform/multiselect'
import { useUiSettingsStore, type VehicleTypeOverride } from '@/stores/settingsUi'
import { useDashboardSettingsStore } from '@/stores/settingsDashboard'
import { useVehicleStore } from '@/stores/vehicle'

// What the gateway says the car is, and the override for when it gets that wrong.
const { t } = useI18n()
const settings = useUiSettingsStore()
const dashboardSettings = useDashboardSettingsStore()
const vehicleStore = useVehicleStore()

// The cards that depend on the drivetrain take the chosen type's defaults. Only here, where the
// driver makes the choice: the same choice arriving from another device comes with the cards
// that already follow it, and whatever was changed there since must stay.
function chooseType(type: VehicleTypeOverride) {
settings.vehicleTypeOverride = type
dashboardSettings.applyTypeDefaults(vehicleStore.effectiveVehicleType)
}

const detectedLabel = computed(() => {
const type = vehicleStore.detectedVehicleType
if (type === 'unknown') return null
Expand Down Expand Up @@ -40,13 +50,14 @@ const typeOptions = computed((): { value: VehicleTypeOverride; label: string }[]
<div class="vehicle-type-override">
<label class="text-muted">{{ t('settings.vehicleType.override') }}</label>
<Multiselect
v-model="settings.vehicleTypeOverride"
:model-value="settings.vehicleTypeOverride"
:options="typeOptions"
:searchable="false"
:can-clear="false"
:can-deselect="false"
append-to="body"
class="vehicle-type-select"
@update:model-value="chooseType"
/>
</div>
</div>
Expand Down
Loading
Loading