diff --git a/.env.example b/.env.example index 45536582..d0901bd9 100644 --- a/.env.example +++ b/.env.example @@ -196,6 +196,19 @@ WIDGET_API_KEY= # Leave empty to disable the charging stations feature (the map still works). OPENCHARGEMAP_API_KEY= +# ── Dutch registration (RDW open data) ──────────────────────────────────────── +# For a car with a Dutch plate: the APK expiry date, open recalls and whether the +# car is insured, shown on a dashboard card and sent as notifications. The plate, +# entered under Settings, is the only thing sent (to opendata.rdw.nl); nothing +# about your account or the car's position goes with it. No API key required. +# Off by default; the plate field and the card only appear once this is true. +# RDW_ENABLED=false +# Hours a lookup is kept before the Worker asks the RDW again. +# RDW_REFRESH_HOURS=24 +# Optional Socrata app token (sent as X-App-Token). Without one, requests share +# the lower rate limit every anonymous caller gets. +# RDW_APP_TOKEN= + # ── Overpass API (OpenStreetMap POI data) ───────────────────────────────────── # The Overpass API is used for the fuel station, motorway service area and speed # camera overlays on the map. No API key is required (free/FOSS data). The default diff --git a/README.md b/README.md index f60a7ac3..e06803bb 100644 --- a/README.md +++ b/README.md @@ -48,10 +48,11 @@ The statistics, trip log, maintenance list and light theme are in the [screensho - **Trip history** -- Browse past journeys on an interactive map, each drawn on the roads it took and coloured by speed or by the speed limit, with a heatmap of the roads you drive most. Each trip is saved to the database a few minutes after the car parks. After upgrading from a version that did not save trips, the existing history is saved in the background on the Worker's first start. - **Trip log** -- Mark each trip as business, commute or private (commutes between your home and work are marked automatically), add notes, and export a month or a year as a spreadsheet with the addresses and odometer readings a tax trip log (such as the Dutch *rittenregistratie*) asks for. See [Trip log](#trip-log). - **Maintenance** -- Keep the car's service items (oil, tyres, the yearly inspection, ...) with a distance interval, a time interval or both. GarageStack works out from the odometer and your service log when each one is due, shows the most urgent on the dashboard, and sends a notification when an item reaches 90 % of its interval. Log each service with its date, odometer reading and notes. +- **Dutch registration (RDW)** -- For a car with a Dutch licence plate, a dashboard card with the date the APK runs out and the days left, open recalls with what can go wrong and what the dealer does about it, and whether the car is insured, from the [RDW's open data](https://opendata.rdw.nl). Notifications come for a new recall, the APK a month and a week ahead, and a car with no insurance on record. Off until `RDW_ENABLED=true`; the plate, entered under Settings, is the only thing sent. See [Dutch registration (RDW)](#dutch-registration-rdw). - **Energy statistics** -- Track daily energy consumption, efficiency (Wh/km on a plug-in car, L/100 km on a hybrid), fuel use, electric share, average driving speed, and more, for any period from a week to several years. - **Remote commands** -- Trigger climate pre-conditioning (normal climate, fan only or front defrost), lock or unlock the car, open the boot, and activate the horn and lights remotely from the dashboard. On a plug-in car also start or stop charging, lock or unlock the charging cable, switch battery heating on or off, set the charging and battery heating schedules, and set the charge current limit and, where the car takes one, the charge target. Opening the boot and unlocking the cable ask for a second tap. Each command reports whether the car carried it out, and if it refused, the reason the MG servers gave. - **Climate schedules** -- Switch climate on at a set time on chosen weekdays, or once. A schedule sets the temperature or mode, seat heating and the rear defroster, and can skip mild days by the car's outside temperature. Schedules run on the server, so no browser needs to be open, and each run sends a push notification saying whether the car started climate. -- **Push notifications** -- Browser and in-app alerts for key events: engine started, low or high tyre pressure, a slow puncture, low EV battery, low fuel, a weak 12V battery, car left unlocked, doors or windows left open, the car moving while parked, a charge that finished or stopped early, maintenance coming due, a climate schedule that ran, the messages the official MG app receives, and data that stopped arriving because the MG sign-in failed or the car went quiet. Each device chooses which ones it gets. +- **Push notifications** -- Browser and in-app alerts for key events: engine started, low or high tyre pressure, a slow puncture, low EV battery, low fuel, a weak 12V battery, car left unlocked, doors or windows left open, the car moving while parked, a charge that finished or stopped early, maintenance coming due, the APK running out or a recall on a Dutch plate, a climate schedule that ran, the messages the official MG app receives, and data that stopped arriving because the MG sign-in failed or the car went quiet. Each device chooses which ones it gets. - **Homepage widget** -- A read-only API endpoint for the [gethomepage.dev](https://gethomepage.dev) Custom API widget, exposing key vehicle stats at a glance. - **Log viewer** -- The Logs page shows the entries of the API's and the Worker's log files, newest first and older ones loading as you scroll, filtered by service, level and text, with exceptions folded open on request and a live mode that adds what was written every ten seconds. The files themselves stay on the server, one per day (`LOG_RETAINED_FILES`). - **Full data export** -- Settings > Your data downloads everything GarageStack keeps as one zip: per car its trips, maintenance, schedules, places and every telemetry reading (as CSV), and your notifications and settings. @@ -103,6 +104,7 @@ Cards are shown or hidden automatically based on vehicle type (HEV / PHEV / BEV) | Charging Session | OBC power, cable lock, charging type, last start and end; start or stop charging, lock or unlock the cable, set the charging schedule | PHEV, BEV | | Battery Heating | Pre-heating status and schedule; switch it on or off, set the schedule | PHEV, BEV | | Maintenance | The most urgent state of your maintenance items, linking to the Maintenance page | All | +| APK & recalls | The APK expiry date and days left, open recalls with their risk and repair, and the insurance on record, from the RDW | All (only with `RDW_ENABLED` and a plate set) | | Climate Schedule | When a climate schedule starts climate next | All (off by default) | ### Statistics insights @@ -146,7 +148,7 @@ Before installing, make sure the following are available on your host: - **Docker** 23.0 or later (or Podman with Docker Compose compatibility); the quick start's relative `./garagestack-data` mount needs 23.0 - **Docker Compose** 2.20 or later (bundled with Docker Desktop; on Linux install the `docker-compose-plugin` package) -- required for Option B only - An **MG iSmart account** with your vehicle already linked in the official app -- Outbound internet access on the host so the container can reach the SAIC API and (optionally) Overpass / Open Charge Map +- Outbound internet access on the host so the container can reach the SAIC API and (optionally) Overpass / Open Charge Map / the RDW --- @@ -351,12 +353,16 @@ GarageStack checks your vehicle's state every 5 minutes and sends both a browser | Charging complete | Charging stops while the cable is still connected (plug-in vehicles) | | Charging interrupted | Charging stops because of a fault the car names: the charger, an overheating port, a plug not fully in, or a voltage mismatch (plug-in vehicles; unplugging mid-charge sends nothing) | | Maintenance due | A maintenance item reaches 90 % of its interval, or passes it (checked every 6 hours, 7-day cooldown per item) | +| Open recall | The RDW lists a recall on the car's plate that has not been repaired (once per recall; a recall later reported repaired sends nothing) | +| APK due | The APK runs out within 30 days, and again within 7 days, of the date the RDW has (once each per APK date) | +| APK expired | The APK's last valid day has passed (repeated monthly until the RDW has a new date) | +| No insurance on record | The RDW has no WAM insurance on record for the car (repeated monthly while that lasts) | | MG app message | The official MG app receives a message, such as an alarm or a reminder (sent as it arrives, once per message) | | Scheduled climate | A climate schedule ran: climate started, the car refused, or it did not answer (nothing is sent for a run skipped because the car was in use or the day was mild) | Push notifications require VAPID keys to be configured (`VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY`). Without them, alerts still appear in the in-app notification panel. While GarageStack is open in a browser that allowed notifications but is not subscribed to push, the same alerts show up as system notifications too; a subscribed browser gets them through push instead, so nothing arrives twice. -Settings has a push switch for the device you are on and a per-type checklist for these alerts, which offers only the types the car's drivetrain can produce: the plug-in alerts (low EV battery, charging complete, charging interrupted) are left out for a plain hybrid, and low fuel for a full electric car. Both are kept per device: a type switched off on your phone still reaches your laptop, and still shows up in the notification panel. Only the switch turns push on or off; the checklist never asks the browser for permission. +Settings has a push switch for the device you are on and a per-type checklist for these alerts, which offers only the types the car's drivetrain can produce: the plug-in alerts (low EV battery, charging complete, charging interrupted) are left out for a plain hybrid, low fuel for a full electric car, and the RDW type (APK, recalls and insurance) wherever `RDW_ENABLED` is off. Both are kept per device: a type switched off on your phone still reaches your laptop, and still shows up in the notification panel. Only the switch turns push on or off; the checklist never asks the browser for permission. Notification texts are written by the background worker, which has no browser to take a language from, so their language is a deployment setting: `NOTIFICATION_LANGUAGE=en` (default) or `nl`. The web UI's own language toggle does not affect them. @@ -398,6 +404,19 @@ Only trips the Worker has saved can be logged, so the trip being driven, and one --- +## Dutch registration (RDW) + +For a car with a Dutch licence plate, GarageStack shows what the [RDW](https://opendata.rdw.nl) has on record for it: when the APK runs out, the recalls on the car, and whether it is WAM insured. It is off until the deployment sets `RDW_ENABLED=true`; until then nothing is sent and none of the below appears. + +- **The plate.** Enter it under **Settings > Licence plate**, which is drawn as a Dutch plate. Type it with or without dashes: they are placed as the plate has them while you type (`HPK-98-B`, `12-ABC-3`). The plate is kept per car in GarageStack's own database, without the dashes, and **Remove** clears it along with what the RDW said about it. +- **The lookup.** Saving the plate looks it up straight away, so the card fills at once. After that the Worker asks again every `RDW_REFRESH_HOURS` (a day by default). The plate is the only thing sent, to `opendata.rdw.nl`, together with the RDW's own reference codes for the recalls it lists; no key is needed. +- **The card.** The **APK & recalls** card shows the days the APK has left and how many recalls are open. Clicking it shows the APK date, the insurance on record, each open recall with what can happen and what the dealer does about it, the recalls already repaired, and when the RDW was last asked. The recall texts are the RDW's own, in Dutch. A plate the RDW does not know is said so, rather than shown as an error. +- **Notifications.** A new open recall (once per recall: one reported repaired later sends nothing), the APK a month and a week before it runs out and once it has, and a car with no WAM insurance on record. They are listed under [Push notifications](#push-notifications), and can be switched off per device like the other types. + +The settings, including an optional Socrata app token for a higher rate limit, are in [`CONFIGURATION.md`](documentation/CONFIGURATION.md#dutch-registration-rdw). + +--- + ## Homepage dashboard widget GarageStack has a read-only endpoint for the [gethomepage.dev](https://gethomepage.dev) Custom API widget, switched on by setting `WIDGET_API_KEY`. The setup and every field it returns are in [`WIDGET.md`](documentation/WIDGET.md). diff --git a/docker-compose.demo.yml b/docker-compose.demo.yml index 77ded41f..b8c56c3e 100644 --- a/docker-compose.demo.yml +++ b/docker-compose.demo.yml @@ -18,6 +18,8 @@ services: # The browser smoke tests drive several pages in parallel from one address, which is far # more traffic than the default per-IP budget is sized for. RATE_LIMIT_GLOBAL_PER_MINUTE: "${RATE_LIMIT_GLOBAL_PER_MINUTE:-2000}" + # The demo's stand-in RDW asks nothing upstream, so the card is on unless this says false. + RDW_ENABLED: "${RDW_ENABLED:-}" ports: - "127.0.0.1:${API_PORT:-5001}:8080" volumes: diff --git a/docker-compose.yml b/docker-compose.yml index aca9e4cb..ad740ac3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,11 @@ x-dotnet-environment: &dotnet-environment OVERPASS__BASEURL: "${OVERPASS__BASEURL:-}" SPEEDCAMERAS__ENABLED: "${SPEEDCAMERAS__ENABLED:-}" OPENCHARGEMAP_API_KEY: "${OPENCHARGEMAP_API_KEY:-}" + # The Dutch registration: the API looks a plate up as it is entered, the Worker keeps it current + # and sends the notifications, so both need the switch. See .env.example. + RDW_ENABLED: "${RDW_ENABLED:-}" + RDW_REFRESH_HOURS: "${RDW_REFRESH_HOURS:-}" + RDW_APP_TOKEN: "${RDW_APP_TOKEN:-}" # Docker keeps container output forever unless told otherwise. The .NET services also write their # own rotated files (LOG_RETAINED_FILES), so a few megabytes of console history per container is diff --git a/docker/all-in-one/README.md b/docker/all-in-one/README.md index 7246017f..5730e907 100644 --- a/docker/all-in-one/README.md +++ b/docker/all-in-one/README.md @@ -54,7 +54,7 @@ By default the web login uses the same `SAIC_USER` and `SAIC_PASSWORD` credentia ### Optional -Every other variable works here as well, under the same name as in Docker Compose. They are all listed, with their defaults, in [`CONFIGURATION.md`](../../documentation/CONFIGURATION.md): push notifications, single sign-on, the map's data sources, tyre pressure bands, the traction battery's real size, the rate limit and the Home Assistant broker login. A few behave differently in this image: +Every other variable works here as well, under the same name as in Docker Compose. They are all listed, with their defaults, in [`CONFIGURATION.md`](../../documentation/CONFIGURATION.md): push notifications, single sign-on, the map's data sources, the Dutch registration (RDW) lookups, tyre pressure bands, the traction battery's real size, the rate limit and the Home Assistant broker login. A few behave differently in this image: | Variable | In this image | |----------|---------------| diff --git a/documentation/ARCHITECTURE.md b/documentation/ARCHITECTURE.md index 0ae94201..c4480b89 100644 --- a/documentation/ARCHITECTURE.md +++ b/documentation/ARCHITECTURE.md @@ -93,6 +93,41 @@ The trip log (`/trip-log`, `TripEndpoints`) shows only saved trips, because only The `AddTripLog` migration clears the trips saved before it and resets the recording line, and the Worker saves them again with their new columns. Nothing is lost: until that migration, nothing a driver could enter was kept on a trip. +## The Dutch registration (RDW) + +With `RDW_ENABLED`, a car's Dutch plate (`Vehicle.LicensePlate`, entered by the user: neither the +gateway nor SAIC reports it) is looked up in the RDW's open data, three Socrata datasets on +`opendata.rdw.nl` asked by plate. `RdwApiClient` sends nothing else, and refuses before sending +anything while the feature is off. + +- **The lookup.** `RdwRefresher` asks for the registration (`m9d7-ebf2`: the APK date, the WAM + insurance and the open-recall flag), then the recall statuses on the plate (`t49b-isb7`: O for + open, P once the manufacturer reported the repair), and the details (`j9yg-7rg9`) only of codes + it has not seen before. A plate the RDW does not know comes back as an empty array, saved as "not + found" rather than as an error. A failure keeps the last good answer and records why + (`LastError`). The result is one `RdwVehicleInfo` row per vehicle, its recalls a JSON array. +- **Who looks.** The API looks a plate up the moment it is entered (`PUT .../license-plate`), so the + card has something to show at once; `RdwSyncService` in the Worker looks every hour for lookups + older than `RDW_REFRESH_HOURS`. Both go through the same refresher and the same row. +- **Notifications.** Only the Worker sends them, from the saved row whoever wrote it. A recall is + pushed once, while open: it keeps when it was first seen and when it was pushed (`FirstSeenAt`, + `NotifiedAt`) across every lookup, so a change from O to P is no news, and a save from a lookup + that started before a push keeps that push's mark. The APK reminders a month and a week ahead and + the expired one name the APK date in their category, so each fires once per date and a renewed + APK starts afresh; days are counted between calendar dates in Amsterdam (`ApkReminder`), never in + 24-hour spans. The expired APK and a car without insurance repeat at most monthly through the + cooldown gate. Nothing is pushed from a lookup older than two refresh intervals: an APK renewed + while the RDW could not be reached must not be reported as running out. +- **In the browser.** `stores/rdw.ts` reads the lookup with the rest of the dashboard and learns + from that first answer whether the deployment looks plates up at all. The plate field + (`LicensePlateInput`) writes a plate the way the car has it: `utils/licensePlate.ts` puts a dash + wherever letters change to digits and splits a group of four in two, which covers every Dutch + side code. The API stores it without the dashes (`LicensePlate.Normalize`), as the RDW keys it. +- **Off.** Both endpoints answer 404 with `rdw.disabled`, which is how the browser learns to leave + out the plate field, the card and the notification type; the Worker job stops before its first + pass. Demo mode switches it on with a stand-in client (`DemoRdwClient`), so no plate leaves there + either. + ## Data kept over time Telemetry is the one table that grows with every poll. The Worker's `HousekeepingService` runs every six hours and keeps it in check. @@ -110,7 +145,7 @@ PostgreSQL reuses the space of removed rows for new ones rather than handing it | --- | --- | --- | | `GarageStack.Core` | Domain models (`Models/`), repository/service interfaces (`Interfaces/`), and pure helpers with no I/O (`Helpers/`) - the shared vocabulary every other project builds on. | nothing (leaf project) | | `GarageStack.Data` | EF Core: `AppDbContext`, migrations, concrete repository implementations, and `Demo/` (in-memory fakes used when `DEMO_MODE=true`). | `Core` | -| `GarageStack.Worker` | The MQTT-ingestion process: `Mqtt/MqttConsumerService` and the message handlers beside it, plus the periodic jobs (maintenance reminders, POI pre-caching, push-notification checks, the slow-puncture check over two weeks of tyre pressures, saving finished trips, housekeeping and telemetry compaction, climate schedules, and the heartbeat the API's readiness check listens for), each a `PeriodicBackgroundService`. | `Core`, `Data` | +| `GarageStack.Worker` | The MQTT-ingestion process: `Mqtt/MqttConsumerService` and the message handlers beside it, plus the periodic jobs (maintenance reminders, POI pre-caching, push-notification checks, the slow-puncture check over two weeks of tyre pressures, saving finished trips, housekeeping and telemetry compaction, climate schedules, RDW lookups, and the heartbeat the API's readiness check listens for), each a `PeriodicBackgroundService`. | `Core`, `Data` | | `GarageStack.Api` | ASP.NET Core minimal APIs (`Endpoints/`), the SignalR hub (`Hubs/`), and Api-only services (outbound MQTT publishing, POI/charging-station lookups, the Postgres-LISTEN-to-SignalR bridge). | `Core`, `Data` | | `GarageStack.Tests` | xUnit tests across all of the above. | all four | @@ -118,7 +153,7 @@ PostgreSQL reuses the space of removed rows for new ones rather than handing it Deployments configure GarageStack through environment variables named for people (`MQTT_HOST`, `OIDC_AUTHORITY`), not in .NET's `Section__Key` form. `EnvironmentAliases` ([src/GarageStack.Core/Configuration/EnvironmentAliases.cs](../src/GarageStack.Core/Configuration/EnvironmentAliases.cs)) is the one table that maps each name onto its setting; both hosts add it as their last configuration source. An empty variable counts as unset, and a setting given by its own key wins over the name, which is how the all-in-one entrypoint passes the values it derives. The compose files therefore pass the variables straight through, and the defaults live in the code. `EnvironmentAliasesTests` fails when `.env.example`, the Unraid template or a compose file mentions a variable that is neither in the table nor consumed by the deployment itself. -The upstream clients and the push sender take typed options (`GeocodingOptions`, `MapMatchingOptions`, `OverpassOptions`, `OpenChargeMapOptions`, `VapidOptions` in `Core/Configuration`), each built once by a `From(IConfiguration)` that reads blank values as defaults. The operator-facing list of every variable is [CONFIGURATION.md](CONFIGURATION.md). +The upstream clients and the push sender take typed options (`GeocodingOptions`, `MapMatchingOptions`, `OverpassOptions`, `OpenChargeMapOptions`, `RdwOptions`, `VapidOptions` in `Core/Configuration`), each built once by a `From(IConfiguration)` that reads blank values as defaults. The operator-facing list of every variable is [CONFIGURATION.md](CONFIGURATION.md). ## Errors @@ -147,6 +182,8 @@ process-local one doesn't already provide. The caches, each in memory or in Post and be queried by bounding box - a job a plain in-memory cache isn't suited for anyway. The brand filter list is a DISTINCT over the `Brand` column, extracted from the upstream metadata at ingest, with a short in-memory cache on top that every tile upsert invalidates. +- RDW lookups (`RdwVehicleInfo`) are kept in Postgres, one row per vehicle, and asked again only + once they are older than `RDW_REFRESH_HOURS`; see [The Dutch registration (RDW)](#the-dutch-registration-rdw). - Reverse-geocoded place names (`GeocodeCacheEntry`) are cached in Postgres for the same reason, keyed by a per-precision coordinate grid plus language. Nominatim's usage policy requires caching rather than re-requesting, and a 90-day TTL is honest for data that changes when a @@ -181,7 +218,7 @@ All four POI types share the same tile-based PostgreSQL cache: If GarageStack ever needs to run more than one Api/Worker instance, revisit this: per-vehicle in-memory state (this cache, the revocation cache, `VehicleCommandGate`, -`NotificationCooldownGate`, the shared `UpstreamRateGate` behind the Overpass/OCM/Nominatim clients) +`NotificationCooldownGate`, the shared `UpstreamRateGate` behind the Overpass/OCM/Nominatim/Valhalla/RDW clients) would all need to move to something shared. ## Authentication diff --git a/documentation/CONFIGURATION.md b/documentation/CONFIGURATION.md index e2afbad0..077d6f19 100644 --- a/documentation/CONFIGURATION.md +++ b/documentation/CONFIGURATION.md @@ -92,6 +92,22 @@ The full guide, with provider examples, is [`AUTHENTICATION.md`](AUTHENTICATION. | `MAPMATCHING__ENABLED` | `true` | `false` keeps trip geometry away from the matcher: trips are drawn from their raw fixes. | | `MAPMATCHING__BASEURL` | `https://valhalla1.openstreetmap.de` | Valhalla endpoint for snapping trips onto roads. | +## Dutch registration (RDW) + +For a car with a Dutch licence plate, GarageStack can show when its APK runs out, its open +recalls and whether it is insured, from the [RDW's open data](https://opendata.rdw.nl). Off +until you switch it on. **The plate is the only thing that leaves your network**: it is sent to +`opendata.rdw.nl`, together with the RDW's own reference codes for the recalls it lists. Nothing +about your account, the car's position or its telemetry goes with it. The RDW's data is public, +needs no key and is free to reuse. The plate itself is entered under Settings > Licence plate, +which only appears once this is on. + +| Variable | Default | What it does | +| --- | --- | --- | +| `RDW_ENABLED` | `false` | `true` looks the plate up at the RDW: once as soon as it is entered, then by the Worker every `RDW_REFRESH_HOURS`. The dashboard gets an RDW card, and notifications come for a new open recall, the APK a month and a week before it runs out and once it has, and a car with no WAM insurance on record. Off, nothing is sent, and the plate field, the card and the notification type are not shown. | +| `RDW_REFRESH_HOURS` | `24` | Hours a lookup is kept before the Worker asks again. One hour at the least. | +| `RDW_APP_TOKEN` | none | A Socrata app token, sent as `X-App-Token`. Not needed: requests without one work, but share a lower rate limit with everyone else asking anonymously. Free to create with an account on the RDW's open data portal. | + ## The car | Variable | Default | What it does | diff --git a/documentation/DEMO.md b/documentation/DEMO.md index 8cbf1308..4010bf51 100644 --- a/documentation/DEMO.md +++ b/documentation/DEMO.md @@ -95,6 +95,7 @@ Click the flask icon in the bottom-right corner to open the panel. Changes take | `IVehicleRepository` | Returns one static MG ZS EV | | `ITelemetryRepository` | Serves pre-built snapshots; mutable via `/api/demo/status/{vin}` | | `ITripRepository` | Serves the pre-built trips | +| `IRdwClient` | Answers for the RDW with any plate: an APK due in three weeks, one open recall and one repaired, so the RDW card shows without a car and without a request leaving the machine. The demo seeds the two notifications the Worker would have sent about it. `RDW_ENABLED=false` leaves the card, the plate field and those notifications out | | `IMqttPublisher` | Carries commands out on the demo car: after two seconds the car's state changes and the answer arrives, as from the gateway. Starting to charge without the cable plugged in (Demo Controls) is refused, to show a refusal | No PostgreSQL, Mosquitto, Worker, or SAIC gateway containers are started. @@ -109,3 +110,4 @@ No PostgreSQL, Mosquitto, Worker, or SAIC gateway containers are started. | `FRONTEND_PORT` | `8080` | Host port for the frontend container | | `API_PORT` | `5001` | Host port for the API container | | `CORS_ORIGIN` | `http://localhost:8080` | Allowed CORS origin | +| `RDW_ENABLED` | `true` in demo mode | `false` leaves out the RDW card, the licence plate field and the seeded RDW notifications. Unlike a real deployment, where it is off unless set, the demo shows it: its stand-in RDW sends nothing anywhere. | diff --git a/documentation/HOME_ASSISTANT.md b/documentation/HOME_ASSISTANT.md index 80a87480..070a59cf 100644 --- a/documentation/HOME_ASSISTANT.md +++ b/documentation/HOME_ASSISTANT.md @@ -140,7 +140,7 @@ rest: unit_of_measurement: km ``` -A token only reads, never sees the full export or the server logs, and counts against the same rate limit as the app, so keep `scan_interval` at a minute or more. Other useful endpoints are `/api/vehicles/YOUR_VIN/maintenance` and `/api/vehicles/YOUR_VIN/trips/latest`. +A token only reads, never sees the full export or the server logs, and counts against the same rate limit as the app, so keep `scan_interval` at a minute or more. Other useful endpoints are `/api/vehicles/YOUR_VIN/maintenance` and `/api/vehicles/YOUR_VIN/trips/latest`, and, where `RDW_ENABLED` is on, `/api/vehicles/YOUR_VIN/rdw` (`info.apkDaysLeft` counts the days the APK has left). --- diff --git a/frontend/src/assets/dashboard.css b/frontend/src/assets/dashboard.css index 42318bb2..959b8c83 100644 --- a/frontend/src/assets/dashboard.css +++ b/frontend/src/assets/dashboard.css @@ -135,6 +135,69 @@ background: var(--color-surface-2); } +/* ── RDW card dialog ──────────────────────────────────────── */ + +.rdw-recall { + padding: 0.6rem 0.75rem; + margin-bottom: 0.5rem; + border: 1px solid var(--color-border); + border-left: 3px solid var(--color-warning); + border-radius: 6px; + background: var(--color-surface-2); +} + +.rdw-recall__header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.35rem; +} + +.rdw-recall__icon { + color: var(--color-warning); +} + +.rdw-recall__code { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.rdw-recall__text { + margin: 0 0 0.4rem; + font-size: 0.88rem; + line-height: 1.5; +} + +.rdw-recall__details { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.25rem 0.75rem; + margin: 0; + font-size: 0.84rem; + line-height: 1.5; +} + +.rdw-recall__details dt { + font-weight: 600; + color: var(--color-text-muted); +} + +.rdw-recall__details dd { + margin: 0; +} + +.rdw-card__meta { + margin: 0.75rem 0 0; + font-size: 0.78rem; + color: var(--color-text-muted); +} + +@media (width <= 480px) { + .rdw-recall__details { + grid-template-columns: 1fr; + } +} + /* ── Large desktop (1440px+) ──────────────────────────────── */ @media (width >= 1440px) { diff --git a/frontend/src/assets/settings.css b/frontend/src/assets/settings.css index 9eb9bae2..d5fb0982 100644 --- a/frontend/src/assets/settings.css +++ b/frontend/src/assets/settings.css @@ -186,7 +186,8 @@ /* ── API tokens ───────────────────────────────────────────── */ /* A name and its button, or a new token and its copy button, on one line. */ -.api-token-form { +.api-token-form, +.license-plate-form { display: flex; gap: 0.5rem; align-items: center; diff --git a/frontend/src/cards/__tests__/registry.spec.ts b/frontend/src/cards/__tests__/registry.spec.ts index 6d226179..893d8078 100644 --- a/frontend/src/cards/__tests__/registry.spec.ts +++ b/frontend/src/cards/__tests__/registry.spec.ts @@ -17,8 +17,9 @@ function context( status: Partial, vehicleType: VehicleType = 'phev', latestTrip: TripSummary | null = null, + rdwPlateSet = false, ): CardDataContext { - return { status: status as TelemetrySnapshot, vehicleType, latestTrip } + return { status: status as TelemetrySnapshot, vehicleType, latestTrip, rdwPlateSet } } describe('card registry', () => { @@ -28,6 +29,11 @@ describe('card registry', () => { expect(withoutIcon).toEqual([]) }) + it('shows the RDW card only for a plate the deployment looks up', () => { + expect(cardHasData('rdw', context({}))).toBe(false) + expect(cardHasData('rdw', context({}, 'phev', null, true))).toBe(true) + }) + it('treats a card without a predicate as always having data', () => { expect(cardHasData('odometer', context({}))).toBe(true) }) diff --git a/frontend/src/cards/registry.ts b/frontend/src/cards/registry.ts index 789c56d1..269d5827 100644 --- a/frontend/src/cards/registry.ts +++ b/frontend/src/cards/registry.ts @@ -13,6 +13,8 @@ export interface CardDataContext { status: TelemetrySnapshot vehicleType: VehicleType latestTrip: TripSummary | null + /** The deployment looks plates up at the RDW, and the car has one. */ + rdwPlateSet: boolean } interface CardDefinition { @@ -129,6 +131,8 @@ export const CARD_DEFINITIONS = [ hasData: ({ latestTrip }) => latestTrip !== null && latestTrip.maxSpeedKmh !== null, }, { id: 'maintenance', icon: 'screwdriver-wrench' }, + // Shown wherever there is a plate to show it for; a deployment without RDW lookups never has one. + { id: 'rdw', icon: 'clipboard-check', hasData: ({ rdwPlateSet }) => rdwPlateSet }, // Off until asked for: most cars have no schedule, and the climate card links to them anyway. { id: 'climateSchedule', icon: 'calendar-days', defaultVisible: () => false }, ] as const satisfies readonly CardDefinition[] diff --git a/frontend/src/cards/useCardData.ts b/frontend/src/cards/useCardData.ts index a873a82c..0ad8f05f 100644 --- a/frontend/src/cards/useCardData.ts +++ b/frontend/src/cards/useCardData.ts @@ -1,5 +1,6 @@ import { computed } from 'vue' import { useVehicleStore } from '@/stores/vehicle' +import { useRdwStore } from '@/stores/rdw' import type { CardDataContext } from './registry' /** @@ -9,6 +10,7 @@ import type { CardDataContext } from './registry' */ export function useCardData() { const store = useVehicleStore() + const rdw = useRdwStore() return computed((): CardDataContext | null => { const status = store.currentStatus @@ -17,6 +19,7 @@ export function useCardData() { status, vehicleType: store.effectiveVehicleType, latestTrip: store.latestTrip, + rdwPlateSet: rdw.hasPlate, } }) } diff --git a/frontend/src/components/DashboardCardContent.vue b/frontend/src/components/DashboardCardContent.vue index 2c979f00..269d9985 100644 --- a/frontend/src/components/DashboardCardContent.vue +++ b/frontend/src/components/DashboardCardContent.vue @@ -22,6 +22,7 @@ import LightsCard from './LightsCard.vue' import ChargingSessionCard from './ChargingSessionCard.vue' import BatteryHeatingCard from './BatteryHeatingCard.vue' import MaintenanceSummaryCard from './MaintenanceSummaryCard.vue' +import RdwCard from './RdwCard.vue' import ClimateScheduleSummaryCard from './ClimateScheduleSummaryCard.vue' import { formatNumber } from '@/utils/format' import { energyUnit, hvBatteryReading, litres, litresPer100Km, whPerKm } from '@/utils/energy' @@ -365,6 +366,9 @@ const activeSimpleCard = computed(() => { + + + diff --git a/frontend/src/components/ExpandableStatusCard.vue b/frontend/src/components/ExpandableStatusCard.vue index 2280163e..c29b2fc2 100644 --- a/frontend/src/components/ExpandableStatusCard.vue +++ b/frontend/src/components/ExpandableStatusCard.vue @@ -7,6 +7,8 @@ withDefaults( icon: string title: string value: string | number | null + /** A short line under the value, as on a plain status card. */ + note?: string variant?: 'success' | 'warning' | 'danger' | 'info' clickable?: boolean }>(), @@ -24,6 +26,7 @@ const open = defineModel('open', { default: false }) :icon="icon" :label="title" :value="value" + :note="note" :variant="variant" :clickable="clickable" @click="open = true" diff --git a/frontend/src/components/NotificationPanel.vue b/frontend/src/components/NotificationPanel.vue index cb32f2e4..6639df68 100644 --- a/frontend/src/components/NotificationPanel.vue +++ b/frontend/src/components/NotificationPanel.vue @@ -33,6 +33,7 @@ const CATEGORY_ICONS: Record = { function categoryIcon(category: string | null): string[] { if (category?.startsWith('maintenance-')) return ['fas', 'screwdriver-wrench'] + if (category?.startsWith('rdw-')) return ['fas', 'clipboard-check'] return (category && CATEGORY_ICONS[category]) || ['fas', 'bell'] } diff --git a/frontend/src/components/RdwCard.vue b/frontend/src/components/RdwCard.vue new file mode 100644 index 00000000..59e0e761 --- /dev/null +++ b/frontend/src/components/RdwCard.vue @@ -0,0 +1,194 @@ + + + diff --git a/frontend/src/components/__tests__/RdwCard.spec.ts b/frontend/src/components/__tests__/RdwCard.spec.ts new file mode 100644 index 00000000..2f846bcd --- /dev/null +++ b/frontend/src/components/__tests__/RdwCard.spec.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { createI18n } from 'vue-i18n' +import en from '@/locales/en.json' +import type { RdwInfo, RdwRecall } from '@/services/rdwApi' +import { useRdwStore } from '@/stores/rdw' +import RdwCard from '../RdwCard.vue' + +const i18n = createI18n({ legacy: false, locale: 'en', messages: { en } }) +const global = { + plugins: [i18n], + stubs: { DetailModal: { template: '
' }, FontAwesomeIcon: true }, +} + +function recall(overrides: Partial = {}): RdwRecall { + return { + code: 'MGP230061', + open: true, + defect: 'De aardingskabel van de hoogvoltkachel is mogelijk niet goed vastgezet.', + risk: 'De isolatiebekleding kan vlam vatten.', + repair: 'De dealer zet de aardingskabel vast.', + severity: 'ERN', + firstSeenAt: '2026-10-16T08:00:00Z', + ...overrides, + } +} + +function info(overrides: Partial = {}): RdwInfo { + return { + found: true, + make: 'MG', + model: 'MG EHS PLUG-IN HYBRID', + apkExpiresOn: '2026-11-09', + apkDaysLeft: 21, + wamInsured: true, + openRecallIndicator: true, + recalls: [recall()], + fetchedAt: '2026-10-19T06:00:00Z', + lastError: null, + ...overrides, + } +} + +function mountWith(lookup: RdwInfo | null, licensePlate = 'HPK98B') { + const store = useRdwStore() + store.available = true + store.status = { licensePlate, info: lookup } + return mount(RdwCard, { global }) +} + +describe('RdwCard', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('shows the days the APK has left, and the open recalls under it', () => { + const card = mountWith(info()).find('.status-card') + + expect(card.find('.status-card__value').text()).toBe('21 days left') + expect(card.find('.status-card__note').text()).toBe('1 open recall') + expect(card.classes()).toContain('status-card--warning') + }) + + it('spells each open recall out with its risk and repair', () => { + const wrapper = mountWith(info()) + const recallText = wrapper.find('.rdw-recall').text() + + expect(recallText).toContain('MGP230061') + expect(recallText).toContain('Serious') + expect(recallText).toContain('De isolatiebekleding kan vlam vatten.') + expect(recallText).toContain('De dealer zet de aardingskabel vast.') + }) + + it('lists repaired recalls apart from the open ones', () => { + const wrapper = mountWith(info({ recalls: [recall({ code: 'MGP200235', open: false })] })) + + expect(wrapper.findAll('.rdw-recall')).toHaveLength(0) + expect(wrapper.text()).toContain('The RDW lists no open recalls for this car.') + expect(wrapper.text()).toContain('MGP200235') + }) + + it('says when the RDW was asked', () => { + const meta = mountWith(info()) + .findAll('.rdw-card__meta') + .map((line) => line.text()) + + expect(meta.some((line) => line.startsWith('From the RDW'))).toBe(true) + }) + + it('is green with a month or more to go and nothing to repair', () => { + const card = mountWith(info({ apkDaysLeft: 200, recalls: [] })).find('.status-card') + + expect(card.classes()).toContain('status-card--success') + expect(card.find('.status-card__note').text()).toBe('No open recalls') + }) + + it('is red once the APK has run out', () => { + const card = mountWith(info({ apkDaysLeft: -3, recalls: [] })).find('.status-card') + + expect(card.find('.status-card__value').text()).toBe('Expired') + expect(card.classes()).toContain('status-card--danger') + }) + + it('is red without insurance on record, and says so', () => { + const wrapper = mountWith(info({ apkDaysLeft: 200, recalls: [], wamInsured: false })) + + expect(wrapper.find('.status-card').classes()).toContain('status-card--danger') + expect(wrapper.text()).toContain('None on record') + }) + + it('reads the last day as the last day', () => { + expect( + mountWith(info({ apkDaysLeft: 0 })) + .find('.status-card__value') + .text(), + ).toBe('Last day') + }) + + it('says the RDW does not know the plate', () => { + const wrapper = mountWith(info({ found: false, recalls: [] }), 'ZZ999Z') + + expect(wrapper.find('.status-card__value').text()).toBe('Plate unknown') + expect(wrapper.text()).toContain('ZZ-999-Z') + }) + + it('waits for a first lookup', () => { + const card = mountWith(null).find('.status-card') + + expect(card.find('.status-card__value').text()).toBe('Not looked up yet') + expect(card.find('.status-card__note').text()).toBe('Plate HPK-98-B') + }) + + it('says when the last attempt failed', () => { + const wrapper = mountWith(info({ lastError: 'The RDW answered 500' })) + + expect(wrapper.find('.text-danger').text()).toContain('The last attempt to ask the RDW failed') + }) +}) diff --git a/frontend/src/components/settings/LicensePlateInput.vue b/frontend/src/components/settings/LicensePlateInput.vue new file mode 100644 index 00000000..13a90e8d --- /dev/null +++ b/frontend/src/components/settings/LicensePlateInput.vue @@ -0,0 +1,157 @@ + + + + + diff --git a/frontend/src/components/settings/LicensePlateSetting.vue b/frontend/src/components/settings/LicensePlateSetting.vue new file mode 100644 index 00000000..e3c630aa --- /dev/null +++ b/frontend/src/components/settings/LicensePlateSetting.vue @@ -0,0 +1,76 @@ + + + diff --git a/frontend/src/components/settings/NotificationTypeSettings.vue b/frontend/src/components/settings/NotificationTypeSettings.vue index c144ba4a..0f6bd7ce 100644 --- a/frontend/src/components/settings/NotificationTypeSettings.vue +++ b/frontend/src/components/settings/NotificationTypeSettings.vue @@ -3,6 +3,7 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import { useUiSettingsStore } from '@/stores/settingsUi' import { useVehicleStore } from '@/stores/vehicle' +import { useRdwStore } from '@/stores/rdw' import { useNotificationPushSync } from '@/composables/useNotificationPushSync' import SettingsToggle from '../SettingsToggle.vue' import { @@ -14,6 +15,7 @@ import { const { t } = useI18n() const settings = useUiSettingsStore() const vehicleStore = useVehicleStore() +const rdw = useRdwStore() const { pushSupported, pushEnabled, @@ -45,12 +47,16 @@ const NOTIFICATION_CATEGORY_LABEL_KEYS: Record = 'gateway-login': 'gatewayLogin', 'data-stale': 'dataStale', maintenance: 'maintenance', + rdw: 'rdw', } // Only the types this drivetrain can actually produce: a plain hybrid never charges, so offering -// it a "charging complete" switch is a control that does nothing. +// it a "charging complete" switch is a control that does nothing. The RDW's only where the +// deployment looks plates up. const offeredNotificationTypes = computed(() => - notificationCategoryIdsFor(vehicleStore.effectiveVehicleType), + notificationCategoryIdsFor(vehicleStore.effectiveVehicleType).filter( + (id) => id !== 'rdw' || rdw.available === true, + ), ) const notificationTypeOptions = computed(() => diff --git a/frontend/src/components/settings/SettingsModal.vue b/frontend/src/components/settings/SettingsModal.vue index 6933d1c5..b7727b6c 100644 --- a/frontend/src/components/settings/SettingsModal.vue +++ b/frontend/src/components/settings/SettingsModal.vue @@ -1,9 +1,11 @@