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
54 changes: 45 additions & 9 deletions README.md

Large diffs are not rendered by default.

40 changes: 40 additions & 0 deletions docker/all-in-one/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,46 @@ docker start garagestack

To restore, stop the container, replace `garagestack-data` with the backup, and start it again. Only the database (`garagestack-data/db/postgres`) and DataProtection keys (`garagestack-data/dataprotection`) actually matter for disaster recovery -- losing the keys just logs everyone out, it doesn't lose any vehicle data.

A dump of the database alone, taken while the container runs, is a smaller backup that also moves between PostgreSQL versions:

```bash
docker exec -u postgres garagestack pg_dump --clean --if-exists garagestack > garagestack-backup.sql
```

### Moving to a new PostgreSQL major version

The image bundles PostgreSQL 18, and a PostgreSQL major version cannot read the data directory of another. An update never changes it on its own: when a release moves the image to the next major version, its release notes say so, and the database moves over by dump and restore. Replace `garagestack` with your `POSTGRES_DB` if you changed it.

1. **Dump the database with the image you run now**, as above, and keep a copy of the whole folder too:

```bash
docker exec -u postgres garagestack pg_dump --clean --if-exists garagestack > garagestack-backup.sql
docker stop garagestack
cp -r garagestack-data garagestack-data-backup
```

2. **Put the old database aside**, so the new image starts a fresh one. `.db_initialized` is what tells the container its database is set up already:

```bash
docker rm garagestack
mv garagestack-data/db/postgres garagestack-data/db/postgres-old
rm garagestack-data/.db_initialized
```

3. **Start the new image** with your usual `docker run` command, and wait for the web interface to answer. It creates an empty database, keeping the password in `.postgres_password`.

4. **Load the dump into it**, with the API and the Worker stopped so nothing writes meanwhile. The dump drops the empty tables before it loads its own:

```bash
docker exec garagestack supervisorctl stop api worker
docker exec -i -u postgres garagestack psql -v ON_ERROR_STOP=1 garagestack < garagestack-backup.sql
docker exec garagestack supervisorctl start api worker
```

5. **Check the app**, then remove `garagestack-data/db/postgres-old` and the backup copy once you are sure. To go back instead, stop the container, put `garagestack-data-backup` back in place of `garagestack-data`, and run the previous image.

Docker Compose with the bundled database works the same way: dump with `docker compose exec postgres pg_dump ...`, start the new `postgres` image on a fresh volume, and restore with `psql` (see [Backup and restore](../../README.md#backup-and-restore)).

## Troubleshooting

### 28P01: password authentication failed for user "garagestack"
Expand Down
8 changes: 5 additions & 3 deletions docker/mosquitto-auth.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ if [ -n "$HA_MQTT_USERNAME" ]; then
# holds on each connect, so write access to all of saic/# would let a restarted Home
# Assistant broker overwrite fresh state here with its stale copies (the gateway's LWT
# among them, which leaves every entity Unavailable).
# homeassistant/#: the gateway's retained discovery configs. homeassistant/status: Home
# Assistant's own online/offline message, which makes the gateway re-publish discovery.
printf '\nuser %s\ntopic read saic/#\ntopic write saic/+/vehicles/+/+/+/set\ntopic write saic/+/vehicles/+/+/+/+/set\ntopic read homeassistant/#\ntopic write homeassistant/status\n' \
# homeassistant/#: the gateway's retained discovery configs, and GarageStack's own (APK,
# recalls, maintenance, last trip). garagestack/#: the state those read their values from.
# homeassistant/status: Home Assistant's own online/offline message, which makes the gateway
# re-publish discovery.
printf '\nuser %s\ntopic read saic/#\ntopic write saic/+/vehicles/+/+/+/set\ntopic write saic/+/vehicles/+/+/+/+/set\ntopic read homeassistant/#\ntopic write homeassistant/status\ntopic read garagestack/#\n' \
"$HA_MQTT_USERNAME" >> "$acl_file"
fi

Expand Down
3 changes: 2 additions & 1 deletion documentation/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ without a real car, see [DEMO.md](DEMO.md).
- **saic-mqtt-gateway** (`saicismartapi/saic-python-mqtt-gateway`, pinned in `docker-compose.yml`) polls the SAIC/MG cloud API on GarageStack's behalf and publishes telemetry to MQTT, and relays commands published back to MQTT to the cloud API. This is the only piece of the stack that isn't part of this repo.
- **Mosquitto** is the MQTT broker all telemetry and commands flow through. Requires auth; not exposed to the LAN by default. Its password and ACL files are generated on every start by [docker/mosquitto-auth.sh](../docker/mosquitto-auth.sh), shared by Compose and the all-in-one image. An optional, restricted second login lets Home Assistant consume the gateway's MQTT discovery directly (see [HOME_ASSISTANT.md](HOME_ASSISTANT.md)).
- **Worker** (`GarageStack.Worker`) subscribes to MQTT and writes telemetry to Postgres. Runs on its own, no inbound HTTP.
- **Api** (`GarageStack.Api`) serves the REST API and SignalR hub the frontend talks to, and separately publishes outbound MQTT messages for remote commands (lock, climate, etc.).
- **Api** (`GarageStack.Api`) serves the REST API and SignalR hub the frontend talks to, and separately publishes outbound MQTT messages: remote commands (lock, climate, etc.), and, every five minutes, Home Assistant discovery for what only GarageStack knows (`HomeAssistantPublisher`: the APK and recalls, the maintenance due first, the last trip, from `VehicleFactsService`, which the Homepage widget reads too).
- **Postgres** is the only datastore. The Api also uses it as a pub/sub channel (`pg_notify`/`LISTEN`) to learn about writes the Worker makes, so it can push live updates without polling the DB.
- **frontend** is a Vue 3 SPA built and served by nginx; the only service with a public port by default.

Expand Down Expand Up @@ -88,6 +88,7 @@ The trip log (`/trip-log`, `TripEndpoints`) shows only saved trips, because only

- **Odometer.** The recorder stores a reading for each end of a trip as it saves the trip. The start reading is the last one at or before the first fix. The end reading is the last one up to five minutes after the last moving fix. The car reports its final mileage a poll or two after it stops, and the next trip cannot start within those five minutes. The odometer only counts up, so the latest reading is the reading at that moment (`ITelemetryRepository.GetOdometerAtAsync`).
- **Addresses.** `TripPlaceService` looks up both ends through `GeocodeService`, so it shares that cache and its budget of three upstream lookups per request. It then stores the answers on the trip (`StartPlaceJson`, `EndPlaceJson`). The geocode cache forgets a place after 90 days, which is too short for a log kept for a tax year. A "nothing mapped here" answer is shown but not stored, so the trip gets its address once OpenStreetMap has one. The browser asks in rounds until every end is known, the same way the map's trip list does, and stops when place names are switched off.
- **Hiding and joining.** `Trip.IsHidden` keeps a trip out of `GetTripsAsync`, `GetSummariesAsync` and `GetLatestAsync` (the map, the statistics and the dashboard); the log still lists it so it can be shown again, and the browser leaves it out of the totals and the export. A hidden trip is kept rather than deleted, so a migration that records trips again cannot quietly bring back what someone removed. `MergeWithNextAsync` joins a trip with the next saved one in one transaction: the first keeps its id and takes the second's end, fixes, odometer reading and end address, and the second is removed. A purpose someone chose survives the join; an automatic one is worked out again from the new ends. A join is not undone: the fixes of both are in the joined trip, but where one ended is not kept.
- **Purpose and notes** are the only fields a driver changes. The purpose is stored by name and travels as `business`, `commute` or `private`. `TripPurposeJsonConverter` refuses numbers, which the stock enum converter would accept.
- **Export** is built in the browser from the list on screen (`utils/tripLogCsv.ts`), so it matches what the driver checked. Fields are separated by commas in English and by semicolons in Dutch, where the comma is the decimal separator. The file starts with a UTF-8 byte order mark for Excel. Text that a spreadsheet would run as a formula gets a leading apostrophe.

Expand Down
4 changes: 2 additions & 2 deletions documentation/DEMO.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ docker compose --env-file .env.demo -f docker-compose.demo.yml up --build

Open [http://localhost:8080](http://localhost:8080) and log in with **demo / demo**.

The API is available at `http://localhost:5001`. Scalar API docs are not served in production mode, so start the API locally (see below) if you need them.
The API is available at `http://localhost:5001`. Its OpenAPI document is at `/api/openapi/v1.json` for anyone signed in, as in every deployment; the Scalar viewer for it is served only when the API runs locally (see below).

This stack is also what the Playwright smoke tests run against, in CI and locally, because it is the production frontend image with the real nginx config. See [CONTRIBUTING.md](../.github/CONTRIBUTING.md).

Expand All @@ -45,7 +45,7 @@ dotnet run --launch-profile Demo

This starts the API at `http://localhost:5000` with `DEMO_MODE=true`, `Auth:Username=demo`, and `Auth:Password=demo` pre-configured. No database or MQTT connection is made.

The API reference (Scalar) is at [http://localhost:5000/scalar/v1](http://localhost:5000/scalar/v1), and the OpenAPI document at `/openapi/v1.json`. Both are served only in the `Development` and `Demo` environments (`ASPNETCORE_ENVIRONMENT`), never by the Docker images.
The API reference (Scalar) is at [http://localhost:5000/scalar/v1](http://localhost:5000/scalar/v1), and the OpenAPI document at `/openapi/v1.json`. Both are served only in the `Development` and `Demo` environments (`ASPNETCORE_ENVIRONMENT`). Every deployment, the Docker images included, also serves the document at `/api/openapi/v1.json` to whoever is signed in or holds an API token.

**Frontend:**

Expand Down
26 changes: 25 additions & 1 deletion documentation/HOME_ASSISTANT.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

GarageStack and [Home Assistant](https://www.home-assistant.io) can share the same car data. The SAIC MQTT gateway inside GarageStack already publishes [Home Assistant MQTT discovery](https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery) messages, so all Home Assistant needs is a login on GarageStack's Mosquitto broker. Your car then shows up as a device with sensors (battery, range, tyre pressures, doors, location and more) and controls (lock, climate, charging), with no custom component or YAML.

GarageStack adds sensors of its own to the same device, for what only it knows: the APK date and the days it has left, the open recalls and the insurance from the RDW, the maintenance item due first, and the last trip. See [GarageStack's own sensors](#garagestacks-own-sensors).

**Don't install a separate MG / SAIC integration in Home Assistant as well.** The MG iSmart API allows one active session per account, so a second client logging in with the same account keeps kicking GarageStack out (see [MG iSmart account and session limits](../README.md#mg-ismart-account-and-session-limits)). Sharing the broker means one login and one set of polls against the MG cloud, used by both.

---
Expand All @@ -15,6 +17,8 @@ GarageStack and [Home Assistant](https://www.home-assistant.io) can share the sa
│ homeassistant/.../config (discovery, retained)
▼
Mosquitto ◄──── GarageStack Worker and Api (internal login, full access)
│ the Api publishes ──► garagestack/<vin>/state (its own sensors' values, retained)
│ homeassistant/.../garagestack_<vin>/.../config
▲
└──────── Home Assistant (HA_MQTT_USERNAME, restricted)
```
Expand Down Expand Up @@ -92,6 +96,7 @@ topic saic/+/vehicles/+/+/+/set out 0
topic saic/+/vehicles/+/+/+/+/set out 0
topic homeassistant/# in 0
topic homeassistant/status out 0
topic garagestack/# in 0
```

The car's data only flows in. Only the command topics and `homeassistant/status` go out to GarageStack, so your existing broker's own discovery messages stay local.
Expand Down Expand Up @@ -123,9 +128,28 @@ For any other Mosquitto broker, add the same lines to its configuration file and

---

## GarageStack's own sensors

The Api publishes these every five minutes, and as soon as one changes, on the car's device in Home Assistant. They need nothing beyond the broker login above (and, through a bridge, its `garagestack/#` line). A sensor appears once it has something to show and disappears again when it no longer does: the RDW ones need `RDW_ENABLED` and a plate, the maintenance ones a maintenance item, the trip ones a trip.

| Entity | Shows |
| ------ | ----- |
| APK expires | The APK's last valid day (a date) |
| APK days left | Days until then, negative once it has run out |
| Open recalls | Recalls on the plate the RDW has not seen repaired |
| WAM insured | On while the RDW has WAM insurance on record |
| Next maintenance | The name of the item due first: the most urgent, then the soonest |
| Next maintenance status | `ok`, `due_soon`, `overdue` or `unknown` |
| Next maintenance due | The date it is due by its time interval |
| Next maintenance days left / distance left | How far off it is, in days and in kilometres |
| Last trip distance | The newest trip's distance (km), the one being driven included |
| Last trip started / ended | When it started and ended |

Each reads its value from one retained JSON message, `garagestack/<VIN>/state`, so an automation can also use the whole object. The Homepage widget serves the same values (see [`WIDGET.md`](WIDGET.md)).

## Reading GarageStack with an API token

The bridge above gives Home Assistant the car's own entities. What GarageStack works out itself (maintenance due, the latest trip, the merged status it shows on the dashboard) can be read with a [read-only API token](../README.md#read-only-api-tokens) and a [REST sensor](https://www.home-assistant.io/integrations/sensor.rest/). Make the token under **Settings > API tokens**, keep it in `secrets.yaml`, and use the car's VIN in the URL:
The broker gives Home Assistant the car's entities and the sensors above. Anything else GarageStack works out (the full maintenance list, the merged status it shows on the dashboard) can be read with a [read-only API token](../README.md#read-only-api-tokens) and a [REST sensor](https://www.home-assistant.io/integrations/sensor.rest/). Make the token under **Settings > API tokens**, keep it in `secrets.yaml`, and use the car's VIN in the URL:

```yaml
# configuration.yaml
Expand Down
12 changes: 12 additions & 0 deletions documentation/WIDGET.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,15 @@ The endpoint returns a flat JSON object. Numeric fields are `null` when the vehi
| `chargingScheduleEndTime` | string | Scheduled charge end time (HH:MM) |
| `onboardChargerPlugStatus` | number | Onboard charger plug presence status (raw integer) |
| `offboardChargerPlugStatus` | number | Offboard (DC) charger plug presence status (raw integer) |
| `apkExpiresOn` | string (date) | The APK's last valid day, as the RDW has it (`RDW_ENABLED` and a plate set; `null` otherwise) |
| `apkDaysLeft` | number | Days until then on the Dutch calendar, negative once it has run out |
| `openRecalls` | number | Recalls on the plate the RDW has not seen repaired |
| `wamInsured` | string | `"Insured"` or `"Not insured"`: whether the RDW has WAM insurance on record |
| `nextMaintenance` | string | The maintenance item due first: the most urgent, then the soonest (`null` without items) |
| `nextMaintenanceStatus` | string | `"OK"`, `"Due soon"`, `"Overdue"` or `"Not tracked yet"`, as the Maintenance page shows it |
| `nextMaintenanceDueDate` | string (ISO 8601) | When it is due by its time interval, if it has one |
| `nextMaintenanceDaysLeft` | number | Days until then, negative once passed |
| `nextMaintenanceKmLeft` | number | Kilometres until it is due by its distance interval, negative once passed |
| `lastTripStartedAt` | string (ISO 8601) | When the newest trip started, the one being driven included |
| `lastTripEndedAt` | string (ISO 8601) | When it ended, or its latest fix while it is still being driven |
| `lastTripDistanceKm` | number | Its distance along the GPS fixes (km) |
Loading
Loading