From 9d69b5b90ce7017ee183fff168c170aca3d0569f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 08:18:47 +0000 Subject: [PATCH 1/4] Look up the Dutch registration at the RDW (APK, recalls, insurance) Off unless RDW_ENABLED is set. The plate, entered per vehicle, is the only thing sent to opendata.rdw.nl: the registration (m9d7-ebf2), the recall statuses on the plate (t49b-isb7) and, for codes not seen before, the recall details (j9yg-7rg9). The API looks a plate up as soon as it is entered; the Worker's RdwSyncService keeps it current every RDW_REFRESH_HOURS and sends the notifications: once per open recall, the APK a month and a week ahead and once expired, and a car with no WAM insurance on record. - RdwOptions, EnvironmentAliases, .env.example, compose, Unraid template and CONFIGURATION.md for RDW_ENABLED, RDW_REFRESH_HOURS and RDW_APP_TOKEN. - Vehicle.LicensePlate and RdwVehicleInfo in one migration. - PUT /api/vehicles/{vin}/license-plate and GET /api/vehicles/{vin}/rdw, both 404 rdw.disabled while off. - A stand-in client and seeded lookup for demo mode. - Parser tests against responses recorded from the RDW. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUS3qyf8owf2McmxrZB6zu --- .env.example | 13 + README.md | 8 +- docker-compose.yml | 5 + documentation/ARCHITECTURE.md | 34 +- documentation/CONFIGURATION.md | 16 + documentation/DEMO.md | 1 + src/GarageStack.Api/Endpoints/RdwEndpoints.cs | 124 ++ src/GarageStack.Api/Program.cs | 1 + src/GarageStack.Api/Services/DataExport.cs | 1 + .../Configuration/EnvironmentAliases.cs | 5 + .../Configuration/RdwOptions.cs | 34 + src/GarageStack.Core/Helpers/ApkReminder.cs | 45 + src/GarageStack.Core/Helpers/LicensePlate.cs | 34 + src/GarageStack.Core/Helpers/RdwRecalls.cs | 62 + src/GarageStack.Core/Interfaces/IRdwClient.cs | 20 + .../Interfaces/IRdwRepository.cs | 24 + .../Interfaces/IVehicleRepository.cs | 3 + .../Models/NotificationCategories.cs | 29 +- src/GarageStack.Core/Models/RdwLookup.cs | 21 + src/GarageStack.Core/Models/RdwVehicleInfo.cs | 82 ++ src/GarageStack.Core/Models/Vehicle.cs | 7 + src/GarageStack.Data/AppDbContext.cs | 19 + src/GarageStack.Data/Demo/DemoRdwClient.cs | 54 + src/GarageStack.Data/Demo/DemoSeeder.cs | 58 +- .../Demo/DemoVehicleRepository.cs | 9 + .../Extensions/ServiceCollectionExtensions.cs | 32 + .../20261019080400_AddRdwLookups.Designer.cs | 1180 +++++++++++++++++ .../20261019080400_AddRdwLookups.cs | 69 + .../Migrations/AppDbContextModelSnapshot.cs | 72 +- .../Repositories/RdwRepository.cs | 114 ++ .../Repositories/VehicleRepository.cs | 8 + src/GarageStack.Data/Services/RdwApiClient.cs | 183 +++ src/GarageStack.Data/Services/RdwRefresher.cs | 89 ++ src/GarageStack.Tests/ApkReminderTests.cs | 64 + .../AuthenticationFlowTests.cs | 73 + .../DeploymentOptionsTests.cs | 52 + src/GarageStack.Tests/FakeHttp.cs | 4 + .../Fixtures/Rdw/recall-details-00BLS4.json | 2 + .../Fixtures/Rdw/recall-details-HPK98B.json | 1 + .../Fixtures/Rdw/recall-status-00BLS4.json | 3 + .../Fixtures/Rdw/recall-status-HPK98B.json | 1 + .../Fixtures/Rdw/registration-00BLS4.json | 1 + .../Fixtures/Rdw/registration-HPK98B.json | 1 + .../Fixtures/Rdw/registration-unknown.json | 1 + .../GarageStack.Tests.csproj | 5 + src/GarageStack.Tests/LicensePlateTests.cs | 49 + .../NotificationCategoriesTests.cs | 4 + src/GarageStack.Tests/RdwApiClientTests.cs | 179 +++ src/GarageStack.Tests/RdwRecallsTests.cs | 107 ++ src/GarageStack.Tests/RdwRefresherTests.cs | 173 +++ src/GarageStack.Tests/RdwSyncServiceTests.cs | 254 ++++ src/GarageStack.Tests/RdwTestFakes.cs | 95 ++ src/GarageStack.Worker/Program.cs | 2 + .../Resources/NotificationStrings.nl.resx | 33 + .../Resources/NotificationStrings.resx | 33 + .../Services/RdwSyncService.cs | 146 ++ unraid/garagestack.xml | 33 + 57 files changed, 3762 insertions(+), 10 deletions(-) create mode 100644 src/GarageStack.Api/Endpoints/RdwEndpoints.cs create mode 100644 src/GarageStack.Core/Configuration/RdwOptions.cs create mode 100644 src/GarageStack.Core/Helpers/ApkReminder.cs create mode 100644 src/GarageStack.Core/Helpers/LicensePlate.cs create mode 100644 src/GarageStack.Core/Helpers/RdwRecalls.cs create mode 100644 src/GarageStack.Core/Interfaces/IRdwClient.cs create mode 100644 src/GarageStack.Core/Interfaces/IRdwRepository.cs create mode 100644 src/GarageStack.Core/Models/RdwLookup.cs create mode 100644 src/GarageStack.Core/Models/RdwVehicleInfo.cs create mode 100644 src/GarageStack.Data/Demo/DemoRdwClient.cs create mode 100644 src/GarageStack.Data/Migrations/20261019080400_AddRdwLookups.Designer.cs create mode 100644 src/GarageStack.Data/Migrations/20261019080400_AddRdwLookups.cs create mode 100644 src/GarageStack.Data/Repositories/RdwRepository.cs create mode 100644 src/GarageStack.Data/Services/RdwApiClient.cs create mode 100644 src/GarageStack.Data/Services/RdwRefresher.cs create mode 100644 src/GarageStack.Tests/ApkReminderTests.cs create mode 100644 src/GarageStack.Tests/Fixtures/Rdw/recall-details-00BLS4.json create mode 100644 src/GarageStack.Tests/Fixtures/Rdw/recall-details-HPK98B.json create mode 100644 src/GarageStack.Tests/Fixtures/Rdw/recall-status-00BLS4.json create mode 100644 src/GarageStack.Tests/Fixtures/Rdw/recall-status-HPK98B.json create mode 100644 src/GarageStack.Tests/Fixtures/Rdw/registration-00BLS4.json create mode 100644 src/GarageStack.Tests/Fixtures/Rdw/registration-HPK98B.json create mode 100644 src/GarageStack.Tests/Fixtures/Rdw/registration-unknown.json create mode 100644 src/GarageStack.Tests/LicensePlateTests.cs create mode 100644 src/GarageStack.Tests/RdwApiClientTests.cs create mode 100644 src/GarageStack.Tests/RdwRecallsTests.cs create mode 100644 src/GarageStack.Tests/RdwRefresherTests.cs create mode 100644 src/GarageStack.Tests/RdwSyncServiceTests.cs create mode 100644 src/GarageStack.Tests/RdwTestFakes.cs create mode 100644 src/GarageStack.Worker/Services/RdwSyncService.cs 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..7d4532d4 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 [`CONFIGURATION.md`](documentation/CONFIGURATION.md#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 | +| RDW | The APK expiry date and days left, open recalls with their risk and repair, and the insurance on record | All (only with `RDW_ENABLED` and a plate set) | | Climate Schedule | When a climate schedule starts climate next | All (off by default) | ### Statistics insights @@ -351,6 +353,10 @@ 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) | 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/documentation/ARCHITECTURE.md b/documentation/ARCHITECTURE.md index 0ae94201..e2d94182 100644 --- a/documentation/ARCHITECTURE.md +++ b/documentation/ARCHITECTURE.md @@ -93,6 +93,36 @@ 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. +- **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 +140,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 +148,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 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..a2e9c5f1 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` switches the card off here too | | `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. diff --git a/src/GarageStack.Api/Endpoints/RdwEndpoints.cs b/src/GarageStack.Api/Endpoints/RdwEndpoints.cs new file mode 100644 index 00000000..d1ed3007 --- /dev/null +++ b/src/GarageStack.Api/Endpoints/RdwEndpoints.cs @@ -0,0 +1,124 @@ +using GarageStack.Core.Configuration; +using GarageStack.Core.Helpers; +using GarageStack.Core.Interfaces; +using GarageStack.Core.Models; +using GarageStack.Data.Services; + +namespace GarageStack.Api.Endpoints; + +/// +/// The car's Dutch registration: its plate, and what the RDW says about it (APK, insurance, +/// recalls). Both answer 404 with while RDW_ENABLED is off, which is +/// also how the browser learns to leave the plate field and the card out. +/// +public static class RdwEndpoints +{ + public const string DisabledCode = "rdw.disabled"; + + public static IEndpointRouteBuilder MapRdwEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/vehicles/{vin}") + .WithTags("RDW") + .RequireAuthorization() + .AddEndpointFilter(); + + // Deployment configuration, not request input. + var options = app.ServiceProvider.GetRequiredService(); + var time = app.ServiceProvider.GetRequiredService(); + + // A GET, so read-only API tokens can read it too. + group.MapGet("/rdw", async (HttpContext httpContext, IRdwRepository rdw, CancellationToken ct) => + { + if (!options.Enabled) return Disabled(); + + var vehicle = httpContext.ResolvedVehicle(); + var info = await rdw.GetAsync(vehicle.Id, ct); + return Results.Ok(ToDto(vehicle.LicensePlate, info, time.GetUtcNow().UtcDateTime)); + }) + .WithSummary("The vehicle's plate and what the RDW last said about it"); + + group.MapPut("/license-plate", async ( + HttpContext httpContext, + LicensePlateRequest req, + IVehicleRepository vehicles, + IRdwRepository rdw, + RdwRefresher refresher, + CancellationToken ct) => + { + if (!options.Enabled) return Disabled(); + + var vehicle = httpContext.ResolvedVehicle(); + var plate = LicensePlate.Normalize(req.LicensePlate); + if (plate is not null && !LicensePlate.IsValid(plate)) + return ApiProblems.BadRequest("rdw.invalidPlate", + $"licensePlate must be letters and digits, {LicensePlate.MaxLength} at most once dashes and spaces are left out"); + + if (plate != vehicle.LicensePlate) + { + // What the RDW said about the old plate is about another car. + await vehicles.SetLicensePlateAsync(vehicle.Id, plate, ct); + await rdw.DeleteAsync(vehicle.Id, ct); + vehicle.LicensePlate = plate; + } + + // Asked straight away, so the card has something to show before the Worker's next round. + var info = plate is null ? null : await refresher.RefreshAsync(vehicle, ct); + return Results.Ok(ToDto(plate, info, time.GetUtcNow().UtcDateTime)); + }) + .WithSummary("Set or clear the vehicle's Dutch plate, and look it up at the RDW right away"); + + return app; + } + + private static IResult Disabled() => + ApiProblems.Problem(StatusCodes.Status404NotFound, DisabledCode, + "RDW lookups are switched off on this deployment (RDW_ENABLED)"); + + internal static RdwStatusDto ToDto(string? licensePlate, RdwVehicleInfo? info, DateTime utcNow) + { + // A lookup for another plate is about another car: the plate changed while it ran. + if (licensePlate is null || info is null || info.LicensePlate != licensePlate) + return new RdwStatusDto(licensePlate, null); + + var recalls = RdwRecalls.Parse(info.RecallsJson) + .Select(r => new RdwRecallDto(r.Code, r.IsOpen, r.Defect, r.Risk, r.Repair, r.Severity, r.FirstSeenAt)) + .ToList(); + return new RdwStatusDto(licensePlate, new RdwInfoDto( + info.Found, + info.Make, + info.Model, + info.ApkExpiresOn, + // Counted on the Dutch calendar, as the notifications count it. + info.ApkExpiresOn is { } expiresOn ? ApkReminder.DaysLeft(expiresOn, utcNow) : null, + info.WamInsured, + info.OpenRecallIndicator, + recalls, + info.FetchedAt, + info.LastError)); + } +} + +public record LicensePlateRequest(string? LicensePlate); + +/// The plate (null when none is set) and the lookup for it (null until there is one). +public record RdwStatusDto(string? LicensePlate, RdwInfoDto? Info); + +/// +/// What the RDW said. false means it knows no vehicle by the plate. +/// is the last answer, why the attempt after it failed. +/// +public record RdwInfoDto( + bool Found, + string? Make, + string? Model, + DateOnly? ApkExpiresOn, + int? ApkDaysLeft, + bool? WamInsured, + bool? OpenRecallIndicator, + IReadOnlyList Recalls, + DateTime? FetchedAt, + string? LastError); + +/// A recall, open or repaired; the texts are the RDW's, in Dutch. +public record RdwRecallDto( + string Code, bool Open, string? Defect, string? Risk, string? Repair, string? Severity, DateTime FirstSeenAt); diff --git a/src/GarageStack.Api/Program.cs b/src/GarageStack.Api/Program.cs index e288f09e..9bd27dee 100644 --- a/src/GarageStack.Api/Program.cs +++ b/src/GarageStack.Api/Program.cs @@ -180,6 +180,7 @@ app.MapMaintenanceEndpoints(); app.MapClimateScheduleEndpoints(); app.MapCommutePlaceEndpoints(); + app.MapRdwEndpoints(); app.MapWidgetEndpoints(); app.MapUpdateEndpoints(); app.MapClientErrorEndpoints(); diff --git a/src/GarageStack.Api/Services/DataExport.cs b/src/GarageStack.Api/Services/DataExport.cs index cbba8fca..62314c5b 100644 --- a/src/GarageStack.Api/Services/DataExport.cs +++ b/src/GarageStack.Api/Services/DataExport.cs @@ -74,6 +74,7 @@ public async Task WriteAsync(Stream target, string? accountKey, CancellationToke { car.Vin, car.Model, + car.LicensePlate, vehicleType = VehicleTypeHelper.GetVehicleType(car), car.CreatedAt, car.LastParkedAt, diff --git a/src/GarageStack.Core/Configuration/EnvironmentAliases.cs b/src/GarageStack.Core/Configuration/EnvironmentAliases.cs index c896c43b..67d90d5d 100644 --- a/src/GarageStack.Core/Configuration/EnvironmentAliases.cs +++ b/src/GarageStack.Core/Configuration/EnvironmentAliases.cs @@ -75,6 +75,11 @@ public static class EnvironmentAliases // Optional extras, each off until given a key. ["WIDGET_API_KEY"] = "Widget:ApiKey", ["OPENCHARGEMAP_API_KEY"] = "OpenChargeMap:ApiKey", + + // The Dutch registration (APK, recalls, insurance), off until switched on. + ["RDW_ENABLED"] = "Rdw:Enabled", + ["RDW_REFRESH_HOURS"] = "Rdw:RefreshHours", + ["RDW_APP_TOKEN"] = "Rdw:AppToken", }; /// diff --git a/src/GarageStack.Core/Configuration/RdwOptions.cs b/src/GarageStack.Core/Configuration/RdwOptions.cs new file mode 100644 index 00000000..26b8de06 --- /dev/null +++ b/src/GarageStack.Core/Configuration/RdwOptions.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Configuration; + +namespace GarageStack.Core.Configuration; + +/// +/// Whether the car's Dutch registration is looked up at the RDW, and how often (RDW_ENABLED, +/// RDW_REFRESH_HOURS, RDW_APP_TOKEN). Off unless the operator turns it on: the plate is the one +/// thing that leaves the network, but it is still something leaving it. The RDW's open data needs +/// no key; a Socrata app token only lifts the rate limit anonymous callers share. +/// +public sealed class RdwOptions +{ + public const string BaseUrl = "https://opendata.rdw.nl"; + + public const int DefaultRefreshHours = 24; + + public bool Enabled { get; init; } + + /// How old a lookup may get before the Worker asks again. One hour at the least. + public int RefreshHours { get; init; } = DefaultRefreshHours; + + /// Sent as X-App-Token when set. + public string? AppToken { get; init; } + + public TimeSpan RefreshInterval => TimeSpan.FromHours(RefreshHours); + + /// Only an explicit "true" turns lookups on; a blank or unparseable interval means a day. + public static RdwOptions From(IConfiguration configuration) => new() + { + Enabled = configuration.SwitchOrDefault("Rdw:Enabled", fallback: false), + RefreshHours = Math.Max(1, configuration.IntegerOrDefault("Rdw:RefreshHours", DefaultRefreshHours)), + AppToken = configuration["Rdw:AppToken"] is { } token && !string.IsNullOrWhiteSpace(token) ? token.Trim() : null, + }; +} diff --git a/src/GarageStack.Core/Helpers/ApkReminder.cs b/src/GarageStack.Core/Helpers/ApkReminder.cs new file mode 100644 index 00000000..91f48b5e --- /dev/null +++ b/src/GarageStack.Core/Helpers/ApkReminder.cs @@ -0,0 +1,45 @@ +namespace GarageStack.Core.Helpers; + +public enum ApkStage +{ + /// More than a month to go, or no APK date known. + None, + + /// 30 days or fewer left. + DueWithinMonth, + + /// 7 days or fewer left, the last valid day included. + DueWithinWeek, + + /// The last valid day has passed. + Expired, +} + +/// +/// When an APK (the Dutch roadworthiness test) is worth a reminder: a month ahead, a week ahead, +/// and once it has run out. The RDW's expiry date is a day on the Dutch calendar, the last one the +/// car may drive on, so days are counted between calendar dates in Amsterdam and never as 24-hour +/// spans: a daylight saving change in between does not move a reminder by a day. +/// +public static class ApkReminder +{ + public const int MonthNoticeDays = 30; + public const int WeekNoticeDays = 7; + + private const string DutchZoneId = "Europe/Amsterdam"; + + /// The zone the RDW's dates are days in. + public static TimeZoneInfo DutchZone { get; } = TimeZoneInfo.FindSystemTimeZoneById(DutchZoneId); + + /// Days from today in the Netherlands to the last valid day: 0 on that day, negative once it has passed. + public static int DaysLeft(DateOnly expiresOn, DateTime utcNow) => + expiresOn.DayNumber - ZoneTime.DateOf(utcNow, DutchZone).DayNumber; + + public static ApkStage StageOf(int daysLeft) => daysLeft switch + { + < 0 => ApkStage.Expired, + <= WeekNoticeDays => ApkStage.DueWithinWeek, + <= MonthNoticeDays => ApkStage.DueWithinMonth, + _ => ApkStage.None, + }; +} diff --git a/src/GarageStack.Core/Helpers/LicensePlate.cs b/src/GarageStack.Core/Helpers/LicensePlate.cs new file mode 100644 index 00000000..f776d871 --- /dev/null +++ b/src/GarageStack.Core/Helpers/LicensePlate.cs @@ -0,0 +1,34 @@ +using System.Text; + +namespace GarageStack.Core.Helpers; + +/// +/// A Dutch registration as the RDW keys it: upper case, without the dashes and spaces it is +/// written with. "ab-123-c" and "AB 123 C" are the same car, AB123C. +/// +public static class LicensePlate +{ + // Dutch plates are six characters; a few older and special series run a little longer. + public const int MaxLength = 8; + + /// + /// The plate in RDW form, or null when nothing is left once the separators are gone. The + /// result is not checked: says whether it can be one. + /// + public static string? Normalize(string? plate) + { + if (plate is null) return null; + + var normalized = new StringBuilder(plate.Length); + foreach (var c in plate) + { + if (c == '-' || char.IsWhiteSpace(c)) continue; + normalized.Append(char.ToUpperInvariant(c)); + } + return normalized.Length == 0 ? null : normalized.ToString(); + } + + /// Whether a normalized plate can be a registration: letters and digits only, eight at most. + public static bool IsValid(string? normalized) => + normalized is { Length: > 0 and <= MaxLength } && normalized.All(char.IsAsciiLetterOrDigit); +} diff --git a/src/GarageStack.Core/Helpers/RdwRecalls.cs b/src/GarageStack.Core/Helpers/RdwRecalls.cs new file mode 100644 index 00000000..8c5a79a0 --- /dev/null +++ b/src/GarageStack.Core/Helpers/RdwRecalls.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using GarageStack.Core.Models; + +namespace GarageStack.Core.Helpers; + +/// +/// Folds the RDW's current recall statuses into the recalls already kept for a vehicle. A code +/// seen before keeps when it was first seen, whether it was pushed, and its details; only its +/// status follows the RDW. That is what keeps a recall from being pushed twice: it is pushed once, +/// while open, and a later change from open to repaired (O to P) is no news. +/// +public static class RdwRecalls +{ + public static IReadOnlyList Parse(string? json) => + SafeJson.TryDeserialize>(json) ?? []; + + public static string Serialize(IReadOnlyList recalls) => JsonSerializer.Serialize(recalls); + + /// The codes in not kept yet: the only ones whose details need asking for. + public static IReadOnlyList Unseen(IReadOnlyList statuses, IReadOnlyList known) + { + var knownCodes = known.Select(r => r.Code).ToHashSet(StringComparer.Ordinal); + return statuses.Select(s => s.Code).Where(code => !knownCodes.Contains(code)).Distinct(StringComparer.Ordinal).ToList(); + } + + /// + /// The recalls as the RDW lists them now, open ones first. A recall the RDW no longer lists is + /// dropped; a new one is first seen at , with whatever + /// holds for it. + /// + public static IReadOnlyList Merge( + IReadOnlyList known, + IReadOnlyList statuses, + IReadOnlyList details, + DateTime now) + { + var knownByCode = known.GroupBy(r => r.Code, StringComparer.Ordinal).ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); + var detailsByCode = details.GroupBy(d => d.Code, StringComparer.Ordinal).ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); + + var merged = new List(); + foreach (var status in statuses.DistinctBy(s => s.Code, StringComparer.Ordinal)) + { + if (knownByCode.TryGetValue(status.Code, out var seen)) + { + merged.Add(seen with { Status = status.Status }); + continue; + } + + var about = detailsByCode.GetValueOrDefault(status.Code); + merged.Add(new RdwRecall(status.Code, status.Status, about?.Defect, about?.Risk, about?.Repair, about?.Severity, now)); + } + + return merged + .OrderByDescending(r => r.IsOpen) + .ThenBy(r => r.Code, StringComparer.Ordinal) + .ToList(); + } + + /// The open recalls no push has gone out for yet. + public static IReadOnlyList ToNotify(IReadOnlyList recalls) => + recalls.Where(r => r.IsOpen && r.NotifiedAt is null).ToList(); +} diff --git a/src/GarageStack.Core/Interfaces/IRdwClient.cs b/src/GarageStack.Core/Interfaces/IRdwClient.cs new file mode 100644 index 00000000..4823d37d --- /dev/null +++ b/src/GarageStack.Core/Interfaces/IRdwClient.cs @@ -0,0 +1,20 @@ +using GarageStack.Core.Models; + +namespace GarageStack.Core.Interfaces; + +/// +/// The RDW's open data, asked by plate. Every call throws when +/// the RDW could not be asked (switched off, rate-limited, unreachable) or answered with something +/// other than data. +/// +public interface IRdwClient +{ + /// The registration, or null when the RDW has no vehicle under . + Task GetRegistrationAsync(string plate, CancellationToken ct = default); + + /// Every recall the RDW lists for the plate, open or repaired. + Task> GetRecallStatusesAsync(string plate, CancellationToken ct = default); + + /// What the recalls with these reference codes are about. A code the RDW does not know is left out. + Task> GetRecallDetailsAsync(IReadOnlyCollection codes, CancellationToken ct = default); +} diff --git a/src/GarageStack.Core/Interfaces/IRdwRepository.cs b/src/GarageStack.Core/Interfaces/IRdwRepository.cs new file mode 100644 index 00000000..fe6bca36 --- /dev/null +++ b/src/GarageStack.Core/Interfaces/IRdwRepository.cs @@ -0,0 +1,24 @@ +using GarageStack.Core.Models; + +namespace GarageStack.Core.Interfaces; + +/// The RDW lookup kept per vehicle (). +public interface IRdwRepository +{ + Task GetAsync(int vehicleId, CancellationToken ct = default); + + /// Every vehicle's row, for the Worker's round. + Task> ListAsync(CancellationToken ct = default); + + /// Creates or replaces the vehicle's row with 's values. + Task SaveAsync(RdwVehicleInfo info, CancellationToken ct = default); + + /// Records that a lookup failed, leaving what the last good one found in place. + Task SetErrorAsync(int vehicleId, string licensePlate, string error, CancellationToken ct = default); + + /// Marks the recalls with these codes as pushed. + Task MarkRecallsNotifiedAsync(int vehicleId, IReadOnlyCollection codes, DateTime notifiedAt, CancellationToken ct = default); + + /// Forgets the vehicle's lookup, when its plate is cleared or changed. + Task DeleteAsync(int vehicleId, CancellationToken ct = default); +} diff --git a/src/GarageStack.Core/Interfaces/IVehicleRepository.cs b/src/GarageStack.Core/Interfaces/IVehicleRepository.cs index cf63a7b7..b1ddbb01 100644 --- a/src/GarageStack.Core/Interfaces/IVehicleRepository.cs +++ b/src/GarageStack.Core/Interfaces/IVehicleRepository.cs @@ -37,4 +37,7 @@ public interface IVehicleRepository /// repeats it after a restart. See . /// Task SetLastMessageIdAsync(int vehicleId, string messageId, CancellationToken ct = default); + + /// Stores the registration (normalized, or null to clear it). See . + Task SetLicensePlateAsync(int vehicleId, string? licensePlate, CancellationToken ct = default); } diff --git a/src/GarageStack.Core/Models/NotificationCategories.cs b/src/GarageStack.Core/Models/NotificationCategories.cs index edb3d5b7..eac0da79 100644 --- a/src/GarageStack.Core/Models/NotificationCategories.cs +++ b/src/GarageStack.Core/Models/NotificationCategories.cs @@ -1,3 +1,5 @@ +using System.Globalization; + namespace GarageStack.Core.Models; /// @@ -40,23 +42,44 @@ public static class NotificationCategories public static string MaintenanceDueSoon(int itemId) => $"maintenance-due-soon-{itemId}"; + // What the RDW categories below are grouped as: the Dutch registration's APK, recalls and insurance. + public const string Rdw = "rdw"; + + /// One per recall code, so each new recall is its own notification. + public static string RdwRecall(string code) => $"rdw-recall-{code}"; + + /// + /// The APK notice days ahead. The expiry date is part of it, so next + /// year's APK, or the right car's after a mistyped plate, is not held back by this one's. + /// + public static string RdwApkDue(int days, DateOnly expiresOn) => + string.Create(CultureInfo.InvariantCulture, $"rdw-apk-due-{days}-{expiresOn:yyyyMMdd}"); + + public static string RdwApkExpired(DateOnly expiresOn) => + string.Create(CultureInfo.InvariantCulture, $"rdw-apk-expired-{expiresOn:yyyyMMdd}"); + + // The RDW has no WAM (third-party liability) insurance on record for the car. + public const string RdwUninsured = "rdw-uninsured"; + /// The types a user can switch off, as the frontend's notification checklist offers them. public static readonly IReadOnlySet Groups = new HashSet(StringComparer.Ordinal) { LowTyre, HighTyre, SlowPuncture, LowEv, ChargingComplete, ChargingInterrupted, EngineStart, UnlockedParked, DoorsOpenParked, WindowsOpenParked, MovedParked, VehicleMessage, ClimateSchedule, Low12V, LowFuel, GatewayLogin, DataStale, - Maintenance, + Maintenance, Rdw, }; /// - /// The type is switched off as: itself, or "maintenance" for the - /// per-item maintenance categories. Mirrors notificationCategoryId in the frontend. + /// The type is switched off as: itself, "maintenance" for the + /// per-item maintenance categories, or "rdw" for the RDW ones. Mirrors notificationCategoryId + /// in the frontend. /// public static string? GroupOf(string? category) => category switch { null => null, _ when category.StartsWith(Maintenance + "-", StringComparison.Ordinal) => Maintenance, + _ when category.StartsWith(Rdw + "-", StringComparison.Ordinal) => Rdw, _ when Groups.Contains(category) => category, _ => null, }; diff --git a/src/GarageStack.Core/Models/RdwLookup.cs b/src/GarageStack.Core/Models/RdwLookup.cs new file mode 100644 index 00000000..113e13dd --- /dev/null +++ b/src/GarageStack.Core/Models/RdwLookup.cs @@ -0,0 +1,21 @@ +namespace GarageStack.Core.Models; + +/// The registration fields GarageStack reads from Gekentekende voertuigen (m9d7-ebf2). +public sealed record RdwRegistration( + string? Make, + string? Model, + DateOnly? ApkExpiresOn, + bool? WamInsured, + bool? OpenRecallIndicator); + +/// One row of Terugroep_actie_status (t49b-isb7): a recall on the plate and where it stands. +public sealed record RdwRecallStatus(string Code, string Status); + +/// +/// One row of Terugroep_actie (j9yg-7rg9): what a recall is about, what can happen because of it, +/// what the dealer does, and the RDW's rating of the risk (). +/// +public sealed record RdwRecallDetails(string Code, string? Defect, string? Risk, string? Repair, string? Severity); + +/// The RDW could not be asked, or did not answer usably. The message is kept as the lookup's last error. +public sealed class RdwUnavailableException(string message, Exception? inner = null) : Exception(message, inner); diff --git a/src/GarageStack.Core/Models/RdwVehicleInfo.cs b/src/GarageStack.Core/Models/RdwVehicleInfo.cs new file mode 100644 index 00000000..d2a6651e --- /dev/null +++ b/src/GarageStack.Core/Models/RdwVehicleInfo.cs @@ -0,0 +1,82 @@ +using System.Text.Json.Serialization; + +namespace GarageStack.Core.Models; + +/// +/// What the RDW last said about a vehicle's registration: when its APK runs out, whether it is +/// insured, and its recalls. One row per vehicle, written by whichever process looked last (the +/// API right after the plate is entered, the Worker on its daily round). +/// +public class RdwVehicleInfo +{ + public int Id { get; set; } + public int VehicleId { get; set; } + public Vehicle Vehicle { get; set; } = null!; + + /// + /// The plate this was looked up for. A row for any other plate than the vehicle's is about + /// another car (the plate was changed while a lookup ran) and is not shown. + /// + public string LicensePlate { get; set; } = string.Empty; + + /// False when the RDW has no vehicle under the plate, a typo far more often than not. + public bool Found { get; set; } + + public string? Make { get; set; } + + /// The trade name, such as "MG EHS PLUG-IN HYBRID". + public string? Model { get; set; } + + /// The last day the current APK is valid, a date on the Dutch calendar. + public DateOnly? ApkExpiresOn { get; set; } + + public bool? WamInsured { get; set; } + + /// The registration's own "open recall" flag, which the per-recall statuses spell out. + public bool? OpenRecallIndicator { get; set; } + + /// The recalls the RDW lists for the plate, as a JSON array of . + public string RecallsJson { get; set; } = "[]"; + + /// When the RDW last answered. Null until it has. + public DateTime? FetchedAt { get; set; } + + /// Why the last attempt failed, or null when it did not. + public string? LastError { get; set; } +} + +/// +/// One recall on the plate. is what can happen because of the defect, and +/// the RDW's rating of it: ERN (serious), GEM (moderate) or NNB (not rated +/// yet). is when GarageStack first saw the code, which is what tells a +/// new recall from one it already knows; is when the push for it went out. +/// A recall found by the API's lookup is seen there but pushed by the Worker, which is why the two +/// are kept apart. +/// +public sealed record RdwRecall( + [property: JsonPropertyName("code")] string Code, + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("defect")] string? Defect, + [property: JsonPropertyName("risk")] string? Risk, + [property: JsonPropertyName("repair")] string? Repair, + [property: JsonPropertyName("severity")] string? Severity, + [property: JsonPropertyName("firstSeenAt")] DateTime FirstSeenAt, + [property: JsonPropertyName("notifiedAt")] DateTime? NotifiedAt = null) +{ + /// RDW's code for a recall the manufacturer still has to repair on this car. + public const string OpenStatus = "O"; + + /// RDW's code for a recall the manufacturer reported as repaired. + public const string RepairedStatus = "P"; + + [JsonIgnore] + public bool IsOpen => Status == OpenStatus; +} + +public static class RdwLimits +{ + public const int NameMaxLength = 128; + public const int TextMaxLength = 2000; + public const int SeverityMaxLength = 8; + public const int ErrorMaxLength = 256; +} diff --git a/src/GarageStack.Core/Models/Vehicle.cs b/src/GarageStack.Core/Models/Vehicle.cs index f7623f47..e14cde1f 100644 --- a/src/GarageStack.Core/Models/Vehicle.cs +++ b/src/GarageStack.Core/Models/Vehicle.cs @@ -42,6 +42,13 @@ public class Vehicle /// public DateTime? TelemetryCompactedUntil { get; set; } + /// + /// The Dutch registration, as writes it, for the + /// RDW lookup. Neither the gateway nor SAIC reports it, so the user enters it. Kept on the + /// vehicle rather than in the browser's settings because the Worker looks it up. + /// + public string? LicensePlate { get; set; } + public ICollection TelemetrySnapshots { get; set; } = []; } diff --git a/src/GarageStack.Data/AppDbContext.cs b/src/GarageStack.Data/AppDbContext.cs index 080b0e56..d0d3dafb 100644 --- a/src/GarageStack.Data/AppDbContext.cs +++ b/src/GarageStack.Data/AppDbContext.cs @@ -1,3 +1,4 @@ +using GarageStack.Core.Helpers; using GarageStack.Core.Models; using Microsoft.EntityFrameworkCore; @@ -21,6 +22,7 @@ public class AppDbContext(DbContextOptions options) : DbContext(op public DbSet ClimateSchedules => Set(); public DbSet CommutePlaces => Set(); public DbSet ApiTokens => Set(); + public DbSet RdwVehicleInfos => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -30,6 +32,23 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) e.HasIndex(v => v.Vin).IsUnique(); e.Property(v => v.Vin).HasMaxLength(17).IsRequired(); e.Property(v => v.LastMessageId).HasMaxLength(VehicleLimits.MessageIdMaxLength); + e.Property(v => v.LicensePlate).HasMaxLength(LicensePlate.MaxLength); + }); + + modelBuilder.Entity(e => + { + e.HasKey(r => r.Id); + // One lookup per vehicle, which is also how every read finds it. + e.HasIndex(r => r.VehicleId).IsUnique(); + e.Property(r => r.LicensePlate).HasMaxLength(LicensePlate.MaxLength).IsRequired(); + e.Property(r => r.Make).HasMaxLength(RdwLimits.NameMaxLength); + e.Property(r => r.Model).HasMaxLength(RdwLimits.NameMaxLength); + e.Property(r => r.RecallsJson).IsRequired(); + e.Property(r => r.LastError).HasMaxLength(RdwLimits.ErrorMaxLength); + e.HasOne(r => r.Vehicle) + .WithMany() + .HasForeignKey(r => r.VehicleId) + .OnDelete(DeleteBehavior.Cascade); }); modelBuilder.Entity(e => diff --git a/src/GarageStack.Data/Demo/DemoRdwClient.cs b/src/GarageStack.Data/Demo/DemoRdwClient.cs new file mode 100644 index 00000000..0a18bbf8 --- /dev/null +++ b/src/GarageStack.Data/Demo/DemoRdwClient.cs @@ -0,0 +1,54 @@ +using GarageStack.Core.Helpers; +using GarageStack.Core.Interfaces; +using GarageStack.Core.Models; + +namespace GarageStack.Data.Demo; + +/// +/// The RDW as the demo car sees it, whatever plate is entered: an APK due in three weeks, one open +/// recall and one the dealer has already repaired. Nothing leaves the machine. +/// +public sealed class DemoRdwClient(TimeProvider time) : IRdwClient +{ + public const string OpenRecallCode = "MGP260001"; + public const string RepairedRecallCode = "MGP250002"; + + public const int ApkDaysLeft = 21; + + public static RdwRegistration Registration(DateTime utcNow) => new( + "MG", + "MG ZS EV", + ZoneTime.DateOf(utcNow, ApkReminder.DutchZone).AddDays(ApkDaysLeft), + WamInsured: true, + OpenRecallIndicator: true); + + public static readonly IReadOnlyList Statuses = + [ + new(OpenRecallCode, RdwRecall.OpenStatus), + new(RepairedRecallCode, RdwRecall.RepairedStatus), + ]; + + // Worded the way the RDW words its recalls, which are in Dutch only. + public static readonly IReadOnlyList Details = + [ + new(OpenRecallCode, + "De software van de laadmodule kan bij snelladen een onjuiste temperatuur doorgeven.", + "Het laden kan onverwacht stoppen en in uitzonderlijke gevallen kan de laadkabel oververhit raken.", + "De voertuigeigenaar wordt verzocht een afspraak te maken met een merkdealer. Deze zal de software van de laadmodule bijwerken.", + "GEM"), + new(RepairedRecallCode, + "De bevestiging van de bestuurdersstoel is mogelijk niet goed aangehaald.", + "Bij een aanrijding kan de stoel loskomen, waardoor het letselrisico toeneemt.", + "De dealer controleert de bevestigingsbouten en haalt ze aan.", + "ERN"), + ]; + + public Task GetRegistrationAsync(string plate, CancellationToken ct = default) => + Task.FromResult(Registration(time.GetUtcNow().UtcDateTime)); + + public Task> GetRecallStatusesAsync(string plate, CancellationToken ct = default) => + Task.FromResult(Statuses); + + public Task> GetRecallDetailsAsync(IReadOnlyCollection codes, CancellationToken ct = default) => + Task.FromResult>(Details.Where(d => codes.Contains(d.Code)).ToList()); +} diff --git a/src/GarageStack.Data/Demo/DemoSeeder.cs b/src/GarageStack.Data/Demo/DemoSeeder.cs index 04441699..caa54cd2 100644 --- a/src/GarageStack.Data/Demo/DemoSeeder.cs +++ b/src/GarageStack.Data/Demo/DemoSeeder.cs @@ -6,9 +6,10 @@ namespace GarageStack.Data.Demo; /// /// Fills the in-memory demo database with the demo vehicle, a handful of maintenance items in -/// various due states and two climate schedules, so those pages and their dashboard cards have -/// something to show. -/// Telemetry, trips and notifications come from the in-memory demo repositories instead. +/// various due states, two climate schedules and an RDW lookup (with the two notifications the +/// Worker would have sent about it), so those pages and their dashboard cards have something to +/// show. +/// Telemetry and trips come from the in-memory demo repositories instead. /// public static class DemoSeeder { @@ -25,6 +26,9 @@ public static async Task SeedAsync(AppDbContext db, CancellationToken ct = defau if (!await db.ClimateSchedules.AnyAsync(ct)) await SeedClimateSchedulesAsync(db, ct); + if (!await db.RdwVehicleInfos.AnyAsync(ct)) + await SeedRdwAsync(db, ct); + if (await db.MaintenanceItems.AnyAsync(ct)) return; @@ -76,6 +80,54 @@ public static async Task SeedAsync(AppDbContext db, CancellationToken ct = defau await db.SaveChangesAsync(ct); } + // What the RDW lookup and the Worker's round after it would have left: the recall seen three + // days ago and pushed then, and the APK's month notice, sent nine days before the three weeks + // it has left now. There is no Worker in demo mode, so the texts are the English ones it sends. + private static async Task SeedRdwAsync(AppDbContext db, CancellationToken ct) + { + var now = DateTime.UtcNow; + var vehicle = DemoVehicleRepository.DemoVehicle; + var registration = DemoRdwClient.Registration(now); + var seenAt = now.AddDays(-3); + var recalls = RdwRecalls.Merge([], DemoRdwClient.Statuses, DemoRdwClient.Details, seenAt) + .Select(r => r.IsOpen ? r with { NotifiedAt = seenAt } : r) + .ToList(); + + db.RdwVehicleInfos.Add(new RdwVehicleInfo + { + VehicleId = vehicle.Id, + LicensePlate = vehicle.LicensePlate!, + Found = true, + Make = registration.Make, + Model = registration.Model, + ApkExpiresOn = registration.ApkExpiresOn, + WamInsured = registration.WamInsured, + OpenRecallIndicator = registration.OpenRecallIndicator, + RecallsJson = RdwRecalls.Serialize(recalls), + FetchedAt = now.AddHours(-2), + }); + + var openRecall = recalls.First(r => r.IsOpen); + db.AppNotifications.AddRange( + new AppNotification + { + Title = "Open recall", + Body = $"The RDW lists an open recall ({openRecall.Code}): {openRecall.Defect}", + CreatedAt = seenAt, + Category = NotificationCategories.RdwRecall(openRecall.Code), + VehicleId = vehicle.Id, + }, + new AppNotification + { + Title = "APK due soon", + Body = $"The APK runs out on {registration.ApkExpiresOn:dd-MM-yyyy}, in {ApkReminder.MonthNoticeDays} days.", + CreatedAt = now.AddDays(-(ApkReminder.MonthNoticeDays - DemoRdwClient.ApkDaysLeft)), + Category = NotificationCategories.RdwApkDue(ApkReminder.MonthNoticeDays, registration.ApkExpiresOn!.Value), + VehicleId = vehicle.Id, + }); + await db.SaveChangesAsync(ct); + } + // Nothing runs them in demo mode (there is no Worker), so their next run is only what the // page shows. private static async Task SeedClimateSchedulesAsync(AppDbContext db, CancellationToken ct) diff --git a/src/GarageStack.Data/Demo/DemoVehicleRepository.cs b/src/GarageStack.Data/Demo/DemoVehicleRepository.cs index 823adeb1..3cbfcf8c 100644 --- a/src/GarageStack.Data/Demo/DemoVehicleRepository.cs +++ b/src/GarageStack.Data/Demo/DemoVehicleRepository.cs @@ -15,6 +15,8 @@ public sealed class DemoVehicleRepository : IVehicleRepository CreatedAt = DateTime.UtcNow.AddMonths(-6), // BType 1: a ZS EV takes a charge target, so the demo shows that control. ConfigJson = """{"hw_version":"MG_BEV_1.0","BType":"1"}""", + // A made-up plate in the current Dutch format; the demo's RDW client answers for any plate. + LicensePlate = "ZZ123Z", }; public Task> GetAllAsync(CancellationToken ct = default) => @@ -38,4 +40,11 @@ public Task SetParkedAsync( public Task SetLastMessageIdAsync(int vehicleId, string messageId, CancellationToken ct = default) => Task.CompletedTask; + + // The one setting the demo keeps: entering a plate is how the RDW card is tried out. + public Task SetLicensePlateAsync(int vehicleId, string? licensePlate, CancellationToken ct = default) + { + if (vehicleId == DemoVehicle.Id) DemoVehicle.LicensePlate = licensePlate; + return Task.CompletedTask; + } } diff --git a/src/GarageStack.Data/Extensions/ServiceCollectionExtensions.cs b/src/GarageStack.Data/Extensions/ServiceCollectionExtensions.cs index d1bb0b8e..50f763a2 100644 --- a/src/GarageStack.Data/Extensions/ServiceCollectionExtensions.cs +++ b/src/GarageStack.Data/Extensions/ServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace GarageStack.Data.Extensions; @@ -34,6 +35,7 @@ public static IServiceCollection AddGarageStackData(this IServiceCollection serv services.AddScoped(); services.AddScoped(); services.AddGarageStackPoiClients(); + services.AddGarageStackRdw(); return services; } @@ -59,10 +61,40 @@ public static IServiceCollection AddDemoServices(this IServiceCollection service services.AddScoped(); services.AddScoped(); services.AddGarageStackPoiClients(); + // The RDW card is part of what the demo shows, answered by a stand-in so no plate leaves: + // on unless RDW_ENABLED says false. + services.AddSingleton(sp => new RdwOptions + { + Enabled = sp.GetRequiredService().SwitchOrDefault("Rdw:Enabled", fallback: true), + }); + services.AddSingleton(); + services.AddRdwLookups(); return services; } + // The RDW lookups. Switched off (RDW_ENABLED unset), the client is still there for the API to + // answer "off" with, but it refuses before sending anything, and nothing calls it anyway. + private static IServiceCollection AddGarageStackRdw(this IServiceCollection services) + { + services.AddHttpClient(RdwApiClient.HttpClientName, client => + { + client.DefaultRequestHeaders.UserAgent.ParseAdd(UpstreamHttp.UserAgent); + client.Timeout = TimeSpan.FromSeconds(20); + }); + services.AddSingleton(sp => RdwOptions.From(sp.GetRequiredService())); + services.AddSingleton(); + return services.AddRdwLookups(); + } + + private static IServiceCollection AddRdwLookups(this IServiceCollection services) + { + services.TryAddSingleton(TimeProvider.System); + services.AddScoped(); + services.AddScoped(); + return services; + } + // Shared by both GarageStack.Api and GarageStack.Worker (each calls AddGarageStackData // or AddDemoServices) so the "ocm"/"overpass" HttpClient config can't drift between processes. private static IServiceCollection AddGarageStackPoiClients(this IServiceCollection services) diff --git a/src/GarageStack.Data/Migrations/20261019080400_AddRdwLookups.Designer.cs b/src/GarageStack.Data/Migrations/20261019080400_AddRdwLookups.Designer.cs new file mode 100644 index 00000000..5cad457b --- /dev/null +++ b/src/GarageStack.Data/Migrations/20261019080400_AddRdwLookups.Designer.cs @@ -0,0 +1,1180 @@ +// +using System; +using System.Collections.Generic; +using GarageStack.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GarageStack.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20261019080400_AddRdwLookups")] + partial class AddRdwLookups + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("GarageStack.Core.Models.ApiToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("AccountKey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("ApiTokens"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.AppNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("VehicleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("IsDeleted", "CreatedAt"); + + b.ToTable("AppNotifications"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.ClimateSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Days") + .HasColumnType("integer"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("LastRunAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastRunDetail") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("LastRunFailedCommand") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("LastRunOutcome") + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NextRunUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OnlyAboveC") + .HasColumnType("double precision"); + + b.Property("OnlyBelowC") + .HasColumnType("double precision"); + + b.Property("RearDefroster") + .HasColumnType("boolean"); + + b.Property("SeatLeftLevel") + .HasColumnType("integer"); + + b.Property("SeatRightLevel") + .HasColumnType("integer"); + + b.Property("StartTime") + .HasColumnType("time without time zone"); + + b.Property("TemperatureC") + .HasColumnType("integer"); + + b.Property("TimeZoneId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("VehicleId") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("VehicleId"); + + b.ToTable("ClimateSchedules"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.CommutePlace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Label") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Latitude") + .HasColumnType("double precision"); + + b.Property("Longitude") + .HasColumnType("double precision"); + + b.Property("RadiusMeters") + .HasColumnType("integer"); + + b.Property("VehicleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("VehicleId"); + + b.ToTable("CommutePlaces"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.GeocodeCacheEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CachedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CellLat") + .HasColumnType("integer"); + + b.Property("CellLng") + .HasColumnType("integer"); + + b.Property("City") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CountryCode") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("DisplayName") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HouseNumber") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Postcode") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Precision") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Road") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("IX_GeocodeCacheEntries_ExpiresAt"); + + b.HasIndex("Precision", "Language", "CellLat", "CellLng") + .IsUnique() + .HasDatabaseName("IX_GeocodeCacheEntries_Precision_Language_CellLatLng"); + + b.ToTable("GeocodeCacheEntries"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.MaintenanceItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IntervalKm") + .HasColumnType("double precision"); + + b.Property("IntervalMonths") + .HasColumnType("integer"); + + b.Property("LastServiceDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastServiceOdometerKm") + .HasColumnType("double precision"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("VehicleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("VehicleId"); + + b.ToTable("MaintenanceItems"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.MaintenanceLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaintenanceItemId") + .HasColumnType("integer"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("OdometerKm") + .HasColumnType("double precision"); + + b.Property("PerformedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("MaintenanceItemId", "PerformedAt"); + + b.ToTable("MaintenanceLogEntries"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.MapMatchCacheEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CachedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MatchedKm") + .HasColumnType("double precision"); + + b.Property("PointIndexesJson") + .HasColumnType("text"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Shape") + .HasColumnType("text"); + + b.Property("SpeedLimitRunsJson") + .HasColumnType("text"); + + b.Property("TraceHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("IX_MapMatchCacheEntries_ExpiresAt"); + + b.HasIndex("Provider", "TraceHash") + .IsUnique() + .HasDatabaseName("IX_MapMatchCacheEntries_Provider_TraceHash"); + + b.ToTable("MapMatchCacheEntries"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.PoiCacheTile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CachedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CellLat") + .HasColumnType("integer"); + + b.Property("CellLng") + .HasColumnType("integer"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PoiType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("IX_PoiCacheTiles_ExpiresAt"); + + b.HasIndex("Source", "PoiType", "CellLat", "CellLng") + .IsUnique() + .HasDatabaseName("IX_PoiCacheTiles_Source_PoiType_CellLatLng"); + + b.ToTable("PoiCacheTiles"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.PoiItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Brand") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CellLat") + .HasColumnType("integer"); + + b.Property("CellLng") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Latitude") + .HasColumnType("double precision"); + + b.Property("Longitude") + .HasColumnType("double precision"); + + b.Property("MetaJson") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("PoiType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Source", "ExternalId") + .IsUnique() + .HasDatabaseName("IX_PoiItems_Source_ExternalId"); + + b.HasIndex("Source", "PoiType", "Brand") + .HasDatabaseName("IX_PoiItems_Source_PoiType_Brand"); + + b.HasIndex("Source", "PoiType", "CellLat", "CellLng") + .HasDatabaseName("IX_PoiItems_Source_PoiType_CellLatLng"); + + b.HasIndex("Source", "PoiType", "Latitude", "Longitude") + .HasDatabaseName("IX_PoiItems_Source_PoiType_LatLon"); + + b.ToTable("PoiItems"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.PushSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text"); + + b.PrimitiveCollection>("ExcludedCategories") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text[]") + .HasDefaultValueSql("'{}'::text[]"); + + b.Property("P256DhKey") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Endpoint") + .IsUnique(); + + b.ToTable("PushSubscriptions"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.RdwVehicleInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApkExpiresOn") + .HasColumnType("date"); + + b.Property("FetchedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Found") + .HasColumnType("boolean"); + + b.Property("LastError") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LicensePlate") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Make") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("OpenRecallIndicator") + .HasColumnType("boolean"); + + b.Property("RecallsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("VehicleId") + .HasColumnType("integer"); + + b.Property("WamInsured") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("VehicleId") + .IsUnique(); + + b.ToTable("RdwVehicleInfos"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.RevokedToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Jti") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("Jti") + .IsUnique(); + + b.ToTable("RevokedTokens"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.TelemetrySnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BatteryHeating") + .HasColumnType("boolean"); + + b.Property("BatteryHeatingScheduleMode") + .HasColumnType("text"); + + b.Property("BatteryHeatingScheduleStartTime") + .HasColumnType("text"); + + b.Property("BatteryVoltage") + .HasColumnType("double precision"); + + b.Property("BmsChargeStatus") + .HasColumnType("text"); + + b.Property("BonnetOpen") + .HasColumnType("boolean"); + + b.Property("ChargeCurrentLimit") + .HasColumnType("text"); + + b.Property("ChargerConnected") + .HasColumnType("boolean"); + + b.Property("ChargingCableLock") + .HasColumnType("boolean"); + + b.Property("ChargingLastEndAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ChargingLastStartAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ChargingScheduleEndTime") + .HasColumnType("text"); + + b.Property("ChargingScheduleMode") + .HasColumnType("text"); + + b.Property("ChargingScheduleStartTime") + .HasColumnType("text"); + + b.Property("ChargingStopReason") + .HasColumnType("text"); + + b.Property("ChargingType") + .HasColumnType("text"); + + b.Property("ClimateMode") + .HasColumnType("text"); + + b.Property("ClimateOn") + .HasColumnType("boolean"); + + b.Property("CurrentJourneyDistance") + .HasColumnType("double precision"); + + b.Property("DriverDoorOpen") + .HasColumnType("boolean"); + + b.Property("DriverWindowOpen") + .HasColumnType("boolean"); + + b.Property("ElectricRangeKm") + .HasColumnType("double precision"); + + b.Property("Elevation") + .HasColumnType("double precision"); + + b.Property("EngineRunning") + .HasColumnType("boolean"); + + b.Property("EvSocPercent") + .HasColumnType("double precision"); + + b.Property("ExteriorTemperature") + .HasColumnType("double precision"); + + b.Property("FuelLevelPercent") + .HasColumnType("double precision"); + + b.Property("FuelRangeKm") + .HasColumnType("double precision"); + + b.Property("Heading") + .HasColumnType("double precision"); + + b.Property("HeatedSeatFrontLeft") + .HasColumnType("integer"); + + b.Property("HeatedSeatFrontRight") + .HasColumnType("integer"); + + b.Property("HvBatteryActive") + .HasColumnType("boolean"); + + b.Property("HvCurrent") + .HasColumnType("double precision"); + + b.Property("HvPower") + .HasColumnType("double precision"); + + b.Property("HvSocKwh") + .HasColumnType("double precision"); + + b.Property("HvTotalCapacityKwh") + .HasColumnType("double precision"); + + b.Property("HvVoltage") + .HasColumnType("double precision"); + + b.Property("InteriorTemperature") + .HasColumnType("double precision"); + + b.Property("IsAvailable") + .HasColumnType("boolean"); + + b.Property("IsCharging") + .HasColumnType("boolean"); + + b.Property("IsLocked") + .HasColumnType("boolean"); + + b.Property("LastChargeEndingPower") + .HasColumnType("double precision"); + + b.Property("LastChargeStateAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastVehicleStateAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Latitude") + .HasColumnType("double precision"); + + b.Property("LightsDippedBeam") + .HasColumnType("boolean"); + + b.Property("LightsMainBeam") + .HasColumnType("boolean"); + + b.Property("LightsSide") + .HasColumnType("boolean"); + + b.Property("Longitude") + .HasColumnType("double precision"); + + b.Property("MileageOfTheDay") + .HasColumnType("double precision"); + + b.Property("MileageSinceLastCharge") + .HasColumnType("double precision"); + + b.Property("ObcCurrent") + .HasColumnType("double precision"); + + b.Property("ObcPowerSinglePhase") + .HasColumnType("double precision"); + + b.Property("ObcPowerThreePhase") + .HasColumnType("double precision"); + + b.Property("ObcVoltage") + .HasColumnType("double precision"); + + b.Property("OdometerKm") + .HasColumnType("double precision"); + + b.Property("OffboardChargerPlugStatus") + .HasColumnType("integer"); + + b.Property("OnboardChargerPlugStatus") + .HasColumnType("integer"); + + b.Property("PassengerDoorOpen") + .HasColumnType("boolean"); + + b.Property("PassengerWindowOpen") + .HasColumnType("boolean"); + + b.Property("PowerUsageOfDay") + .HasColumnType("double precision"); + + b.Property("PowerUsageSinceLastCharge") + .HasColumnType("double precision"); + + b.Property("RawTopic") + .HasColumnType("text"); + + b.Property("RearLeftDoorOpen") + .HasColumnType("boolean"); + + b.Property("RearLeftWindowOpen") + .HasColumnType("boolean"); + + b.Property("RearRightDoorOpen") + .HasColumnType("boolean"); + + b.Property("RearRightWindowOpen") + .HasColumnType("boolean"); + + b.Property("RearWindowDefroster") + .HasColumnType("boolean"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RemainingChargingTime") + .HasColumnType("integer"); + + b.Property("RemoteTemperature") + .HasColumnType("double precision"); + + b.Property("SocTargetPercent") + .HasColumnType("integer"); + + b.Property("Speed") + .HasColumnType("double precision"); + + b.Property("SunRoofOpen") + .HasColumnType("boolean"); + + b.Property("TrunkOpen") + .HasColumnType("boolean"); + + b.Property("TyrePressureFrontLeft") + .HasColumnType("double precision"); + + b.Property("TyrePressureFrontRight") + .HasColumnType("double precision"); + + b.Property("TyrePressureRearLeft") + .HasColumnType("double precision"); + + b.Property("TyrePressureRearRight") + .HasColumnType("double precision"); + + b.Property("VehicleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("VehicleId", "RecordedAt"); + + b.HasIndex(new[] { "VehicleId", "RecordedAt" }, "IX_TelemetrySnapshots_VehicleId_RecordedAt_Gps") + .HasFilter("\"Latitude\" IS NOT NULL AND \"Longitude\" IS NOT NULL"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex(new[] { "VehicleId", "RecordedAt" }, "IX_TelemetrySnapshots_VehicleId_RecordedAt_Gps"), new[] { "Latitude", "Longitude", "Speed", "Heading" }); + + b.HasIndex(new[] { "VehicleId", "RecordedAt" }, "IX_TelemetrySnapshots_VehicleId_RecordedAt_Schedule") + .HasFilter("\"ChargingScheduleMode\" IS NOT NULL OR \"ChargingScheduleStartTime\" IS NOT NULL OR \"ChargingScheduleEndTime\" IS NOT NULL OR \"BatteryHeatingScheduleMode\" IS NOT NULL OR \"BatteryHeatingScheduleStartTime\" IS NOT NULL"); + + b.ToTable("TelemetrySnapshots"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.Trip", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AvgMovingSpeedKmh") + .HasColumnType("double precision"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DistanceKm") + .HasColumnType("double precision"); + + b.Property("EndLatitude") + .HasColumnType("double precision"); + + b.Property("EndLongitude") + .HasColumnType("double precision"); + + b.Property("EndPlaceJson") + .HasColumnType("text"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxSpeedKmh") + .HasColumnType("double precision"); + + b.Property("MovingSpeedSamples") + .HasColumnType("integer"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OdometerEndKm") + .HasColumnType("double precision"); + + b.Property("OdometerStartKm") + .HasColumnType("double precision"); + + b.Property("PointCount") + .HasColumnType("integer"); + + b.Property("PointsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Purpose") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("PurposeIsManual") + .HasColumnType("boolean"); + + b.Property("StartLatitude") + .HasColumnType("double precision"); + + b.Property("StartLongitude") + .HasColumnType("double precision"); + + b.Property("StartPlaceJson") + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VehicleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("VehicleId", "StartedAt") + .IsUnique(); + + b.ToTable("Trips"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.UserSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Json") + .IsRequired() + .HasColumnType("text"); + + b.Property("Section") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountKey", "Section") + .IsUnique(); + + b.ToTable("UserSettings"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.Vehicle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigJson") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastMessageId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastParkedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LicensePlate") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Model") + .HasColumnType("text"); + + b.Property("ParkedLatitude") + .HasColumnType("double precision"); + + b.Property("ParkedLongitude") + .HasColumnType("double precision"); + + b.Property("SaicUser") + .HasColumnType("text"); + + b.Property("Series") + .HasColumnType("text"); + + b.Property("TelemetryCompactedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("TripsRecordedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("Vin") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("character varying(17)"); + + b.HasKey("Id"); + + b.HasIndex("Vin") + .IsUnique(); + + b.ToTable("Vehicles"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.ClimateSchedule", b => + { + b.HasOne("GarageStack.Core.Models.Vehicle", "Vehicle") + .WithMany() + .HasForeignKey("VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.CommutePlace", b => + { + b.HasOne("GarageStack.Core.Models.Vehicle", "Vehicle") + .WithMany() + .HasForeignKey("VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.MaintenanceItem", b => + { + b.HasOne("GarageStack.Core.Models.Vehicle", "Vehicle") + .WithMany() + .HasForeignKey("VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.MaintenanceLogEntry", b => + { + b.HasOne("GarageStack.Core.Models.MaintenanceItem", "MaintenanceItem") + .WithMany("LogEntries") + .HasForeignKey("MaintenanceItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MaintenanceItem"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.RdwVehicleInfo", b => + { + b.HasOne("GarageStack.Core.Models.Vehicle", "Vehicle") + .WithMany() + .HasForeignKey("VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.TelemetrySnapshot", b => + { + b.HasOne("GarageStack.Core.Models.Vehicle", "Vehicle") + .WithMany("TelemetrySnapshots") + .HasForeignKey("VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.Trip", b => + { + b.HasOne("GarageStack.Core.Models.Vehicle", "Vehicle") + .WithMany() + .HasForeignKey("VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.MaintenanceItem", b => + { + b.Navigation("LogEntries"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.Vehicle", b => + { + b.Navigation("TelemetrySnapshots"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/GarageStack.Data/Migrations/20261019080400_AddRdwLookups.cs b/src/GarageStack.Data/Migrations/20261019080400_AddRdwLookups.cs new file mode 100644 index 00000000..ec9aea88 --- /dev/null +++ b/src/GarageStack.Data/Migrations/20261019080400_AddRdwLookups.cs @@ -0,0 +1,69 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GarageStack.Data.Migrations +{ + /// + public partial class AddRdwLookups : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LicensePlate", + table: "Vehicles", + type: "character varying(8)", + maxLength: 8, + nullable: true); + + migrationBuilder.CreateTable( + name: "RdwVehicleInfos", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + VehicleId = table.Column(type: "integer", nullable: false), + LicensePlate = table.Column(type: "character varying(8)", maxLength: 8, nullable: false), + Found = table.Column(type: "boolean", nullable: false), + Make = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + Model = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + ApkExpiresOn = table.Column(type: "date", nullable: true), + WamInsured = table.Column(type: "boolean", nullable: true), + OpenRecallIndicator = table.Column(type: "boolean", nullable: true), + RecallsJson = table.Column(type: "text", nullable: false), + FetchedAt = table.Column(type: "timestamp with time zone", nullable: true), + LastError = table.Column(type: "character varying(256)", maxLength: 256, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RdwVehicleInfos", x => x.Id); + table.ForeignKey( + name: "FK_RdwVehicleInfos_Vehicles_VehicleId", + column: x => x.VehicleId, + principalTable: "Vehicles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_RdwVehicleInfos_VehicleId", + table: "RdwVehicleInfos", + column: "VehicleId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RdwVehicleInfos"); + + migrationBuilder.DropColumn( + name: "LicensePlate", + table: "Vehicles"); + } + } +} diff --git a/src/GarageStack.Data/Migrations/AppDbContextModelSnapshot.cs b/src/GarageStack.Data/Migrations/AppDbContextModelSnapshot.cs index 6ccc0aa2..0b37ca15 100644 --- a/src/GarageStack.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/GarageStack.Data/Migrations/AppDbContextModelSnapshot.cs @@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("ProductVersion", "10.0.13") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -565,6 +565,61 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("PushSubscriptions"); }); + modelBuilder.Entity("GarageStack.Core.Models.RdwVehicleInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApkExpiresOn") + .HasColumnType("date"); + + b.Property("FetchedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Found") + .HasColumnType("boolean"); + + b.Property("LastError") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LicensePlate") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Make") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("OpenRecallIndicator") + .HasColumnType("boolean"); + + b.Property("RecallsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("VehicleId") + .HasColumnType("integer"); + + b.Property("WamInsured") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("VehicleId") + .IsUnique(); + + b.ToTable("RdwVehicleInfos"); + }); + modelBuilder.Entity("GarageStack.Core.Models.RevokedToken", b => { b.Property("Id") @@ -992,6 +1047,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("LastParkedAt") .HasColumnType("timestamp with time zone"); + b.Property("LicensePlate") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + b.Property("Model") .HasColumnType("text"); @@ -1070,6 +1129,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("MaintenanceItem"); }); + modelBuilder.Entity("GarageStack.Core.Models.RdwVehicleInfo", b => + { + b.HasOne("GarageStack.Core.Models.Vehicle", "Vehicle") + .WithMany() + .HasForeignKey("VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Vehicle"); + }); + modelBuilder.Entity("GarageStack.Core.Models.TelemetrySnapshot", b => { b.HasOne("GarageStack.Core.Models.Vehicle", "Vehicle") diff --git a/src/GarageStack.Data/Repositories/RdwRepository.cs b/src/GarageStack.Data/Repositories/RdwRepository.cs new file mode 100644 index 00000000..51cb223b --- /dev/null +++ b/src/GarageStack.Data/Repositories/RdwRepository.cs @@ -0,0 +1,114 @@ +using GarageStack.Core.Helpers; +using GarageStack.Core.Interfaces; +using GarageStack.Core.Models; +using GarageStack.Data.Extensions; +using Microsoft.EntityFrameworkCore; + +namespace GarageStack.Data.Repositories; + +public class RdwRepository(AppDbContext db) : IRdwRepository +{ + public Task GetAsync(int vehicleId, CancellationToken ct = default) => + db.RdwVehicleInfos.AsNoTracking().FirstOrDefaultAsync(r => r.VehicleId == vehicleId, ct); + + public async Task> ListAsync(CancellationToken ct = default) => + await db.RdwVehicleInfos.AsNoTracking().OrderBy(r => r.VehicleId).ToListAsync(ct); + + public async Task SaveAsync(RdwVehicleInfo info, CancellationToken ct = default) + { + var row = await TrackedOrNewAsync(info.VehicleId, ct); + var recallsJson = row.LicensePlate == info.LicensePlate + ? KeepNotified(info.RecallsJson, row.RecallsJson) + : info.RecallsJson; + row.LicensePlate = info.LicensePlate; + row.Found = info.Found; + row.Make = info.Make; + row.Model = info.Model; + row.ApkExpiresOn = info.ApkExpiresOn; + row.WamInsured = info.WamInsured; + row.OpenRecallIndicator = info.OpenRecallIndicator; + row.RecallsJson = recallsJson; + row.FetchedAt = info.FetchedAt; + row.LastError = info.LastError?.Truncate(RdwLimits.ErrorMaxLength); + await SaveOrRetryAsync(info, ct); + } + + public async Task SetErrorAsync(int vehicleId, string licensePlate, string error, CancellationToken ct = default) + { + var row = await TrackedOrNewAsync(vehicleId, ct); + // An error about another plate than the one looked up before says nothing about the old + // plate's data: start that plate afresh rather than show the old car's APK under it. + if (row.LicensePlate != licensePlate) + { + row.LicensePlate = licensePlate; + row.Found = false; + row.Make = row.Model = null; + row.ApkExpiresOn = null; + row.WamInsured = row.OpenRecallIndicator = null; + row.RecallsJson = "[]"; + row.FetchedAt = null; + } + row.LastError = error.Truncate(RdwLimits.ErrorMaxLength); + await db.SaveChangesAsync(ct); + } + + public async Task MarkRecallsNotifiedAsync( + int vehicleId, IReadOnlyCollection codes, DateTime notifiedAt, CancellationToken ct = default) + { + var row = await db.RdwVehicleInfos.FirstOrDefaultAsync(r => r.VehicleId == vehicleId, ct); + if (row is null) return; + + var recalls = RdwRecalls.Parse(row.RecallsJson) + .Select(r => codes.Contains(r.Code) && r.NotifiedAt is null ? r with { NotifiedAt = notifiedAt } : r) + .ToList(); + row.RecallsJson = RdwRecalls.Serialize(recalls); + await db.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(int vehicleId, CancellationToken ct = default) + { + var row = await db.RdwVehicleInfos.FirstOrDefaultAsync(r => r.VehicleId == vehicleId, ct); + if (row is null) return; + db.RdwVehicleInfos.Remove(row); + await db.SaveChangesAsync(ct); + } + + // A lookup builds on the row as it read it. A push the Worker marked since then must survive the + // save, or the next round would push that recall again. + private static string KeepNotified(string incomingJson, string storedJson) + { + var notified = RdwRecalls.Parse(storedJson) + .Where(r => r.NotifiedAt is not null) + .ToDictionary(r => r.Code, r => r.NotifiedAt, StringComparer.Ordinal); + if (notified.Count == 0) return incomingJson; + + return RdwRecalls.Serialize(RdwRecalls.Parse(incomingJson) + .Select(r => r.NotifiedAt is null && notified.TryGetValue(r.Code, out var at) ? r with { NotifiedAt = at } : r) + .ToList()); + } + + private async Task TrackedOrNewAsync(int vehicleId, CancellationToken ct) + { + var row = await db.RdwVehicleInfos.FirstOrDefaultAsync(r => r.VehicleId == vehicleId, ct); + if (row is not null) return row; + + row = new RdwVehicleInfo { VehicleId = vehicleId }; + db.RdwVehicleInfos.Add(row); + return row; + } + + // The API and the Worker can both create a vehicle's first row at the same moment; the loser + // of that race writes over the winner's row instead. + private async Task SaveOrRetryAsync(RdwVehicleInfo info, CancellationToken ct) + { + try + { + await db.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (ex.IsUniqueViolation()) + { + db.ChangeTracker.Clear(); + await SaveAsync(info, ct); + } + } +} diff --git a/src/GarageStack.Data/Repositories/VehicleRepository.cs b/src/GarageStack.Data/Repositories/VehicleRepository.cs index 65f37273..f9151ee3 100644 --- a/src/GarageStack.Data/Repositories/VehicleRepository.cs +++ b/src/GarageStack.Data/Repositories/VehicleRepository.cs @@ -86,6 +86,14 @@ public async Task SetLastMessageIdAsync(int vehicleId, string messageId, Cancell await db.SaveChangesAsync(ct); } + public async Task SetLicensePlateAsync(int vehicleId, string? licensePlate, CancellationToken ct = default) + { + var vehicle = await db.Vehicles.FindAsync([vehicleId], ct); + if (vehicle is null || vehicle.LicensePlate == licensePlate) return; + vehicle.LicensePlate = licensePlate; + await db.SaveChangesAsync(ct); + } + public async Task SetConfigValueAsync(int vehicleId, string key, string value, CancellationToken ct = default) { var vehicle = await db.Vehicles.FindAsync([vehicleId], ct); diff --git a/src/GarageStack.Data/Services/RdwApiClient.cs b/src/GarageStack.Data/Services/RdwApiClient.cs new file mode 100644 index 00000000..8c9b5312 --- /dev/null +++ b/src/GarageStack.Data/Services/RdwApiClient.cs @@ -0,0 +1,183 @@ +using System.Globalization; +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using GarageStack.Core.Configuration; +using GarageStack.Core.Helpers; +using GarageStack.Core.Interfaces; +using GarageStack.Core.Models; +using Microsoft.Extensions.Logging; + +namespace GarageStack.Data.Services; + +/// +/// The RDW's open data (Socrata JSON on opendata.rdw.nl), asked by plate: the registration, its +/// recall statuses, and the details of recalls not seen before. Nothing but the plate, and the +/// reference codes the RDW itself handed out, is sent. Switched off, it asks nothing at all. +/// +public sealed class RdwApiClient( + IHttpClientFactory httpClientFactory, + RdwOptions options, + ILogger logger) : IRdwClient +{ + public const string HttpClientName = "rdw"; + + internal const string RegistrationDataset = "m9d7-ebf2"; + internal const string RecallStatusDataset = "t49b-isb7"; + internal const string RecallDetailsDataset = "j9yg-7rg9"; + + // One gate for all three datasets: they share a host and a rate limit. The API (right after a + // plate is entered) and the Worker each have their own client, so this keeps one process polite. + private readonly UpstreamRateGate _gate = new(); + private static readonly TimeSpan MinInterval = TimeSpan.FromMilliseconds(250); + private static readonly TimeSpan RetryAfterThrottle = TimeSpan.FromMinutes(5); + // A browser waits on the API's lookup; the Worker tries again on its next round. + private static readonly TimeSpan WaitAtMost = TimeSpan.FromSeconds(20); + + public async Task GetRegistrationAsync(string plate, CancellationToken ct = default) + { + var rows = await GetAsync(RegistrationDataset, $"kenteken={Uri.EscapeDataString(plate)}", ct); + + // Socrata answers a plate it does not know with an empty array, not a 404. + if (rows.Count == 0) return null; + + var row = rows[0]; + return new RdwRegistration( + Text(row.Make, RdwLimits.NameMaxLength), + Text(row.Model, RdwLimits.NameMaxLength), + ParseDate(row.ApkExpiresOn), + ParseYesNo(row.WamInsured), + ParseYesNo(row.OpenRecallIndicator)); + } + + public async Task> GetRecallStatusesAsync(string plate, CancellationToken ct = default) + { + var rows = await GetAsync(RecallStatusDataset, $"kenteken={Uri.EscapeDataString(plate)}", ct); + return rows + .Where(r => IsCode(r.Code) && !string.IsNullOrWhiteSpace(r.Status)) + .Select(r => new RdwRecallStatus(r.Code!, r.Status!.Trim())) + .ToList(); + } + + public async Task> GetRecallDetailsAsync( + IReadOnlyCollection codes, CancellationToken ct = default) + { + // The codes go into a SoQL filter, so only ones shaped like the RDW's own make it there. + var wanted = codes.Where(IsCode).Distinct(StringComparer.Ordinal).ToList(); + if (wanted.Count == 0) return []; + + var filter = $"referentiecode_rdw in({string.Join(',', wanted.Select(c => $"'{c}'"))})"; + var rows = await GetAsync(RecallDetailsDataset, $"$where={Uri.EscapeDataString(filter)}", ct); + return rows + .Where(r => IsCode(r.Code)) + .Select(r => new RdwRecallDetails( + r.Code!, + Text(r.Defect, RdwLimits.TextMaxLength), + Text(r.Risk, RdwLimits.TextMaxLength), + Text(r.Repair, RdwLimits.TextMaxLength), + Text(r.Severity, RdwLimits.SeverityMaxLength))) + .ToList(); + } + + private async Task> GetAsync(string dataset, string query, CancellationToken ct) + { + if (!options.Enabled) + throw new RdwUnavailableException("RDW lookups are switched off"); + + using var entry = await _gate.TryEnterAsync(WaitAtMost, failWhenBackingOff: true, ct); + if (entry.Refusal == GateRefusal.BackingOff) + throw new RdwUnavailableException("The RDW asked to slow down; trying again later"); + if (!entry.Entered) + throw new RdwUnavailableException("Another RDW lookup is still running"); + + await _gate.ThrottleAsync(MinInterval, honourBackoff: false, ct); + + using var request = new HttpRequestMessage(HttpMethod.Get, $"{RdwOptions.BaseUrl}/resource/{dataset}.json?{query}"); + if (options.AppToken is { } token) + request.Headers.Add("X-App-Token", token); + + var client = httpClientFactory.CreateClient(HttpClientName); + _gate.MarkRequestSent(); + try + { + using var response = await client.SendAsync(request, ct); + + if (UpstreamRateGate.IsThrottlingStatus((int)response.StatusCode)) + { + _gate.SetBackoff(RetryAfterThrottle); + logger.LogWarning("RDW rate-limited ({Status}); pausing lookups for {Minutes} minutes", + (int)response.StatusCode, (int)RetryAfterThrottle.TotalMinutes); + throw new RdwUnavailableException("The RDW asked to slow down; trying again later"); + } + + if (response.StatusCode != HttpStatusCode.OK) + throw new RdwUnavailableException($"The RDW answered {(int)response.StatusCode}"); + + await using var stream = await response.Content.ReadAsStreamAsync(ct); + return await JsonSerializer.DeserializeAsync>(stream, cancellationToken: ct) ?? []; + } + catch (HttpRequestException ex) + { + throw new RdwUnavailableException("The RDW could not be reached", ex); + } + catch (TaskCanceledException ex) when (!ct.IsCancellationRequested) + { + throw new RdwUnavailableException("The RDW did not answer in time", ex); + } + catch (JsonException ex) + { + throw new RdwUnavailableException("The RDW answered with something other than data", ex); + } + } + + // The RDW's recall reference codes, such as MGP230061. + private static bool IsCode(string? code) => + code is { Length: > 0 and <= 32 } && code.All(char.IsAsciiLetterOrDigit); + + private static string? Text(string? value, int maxLength) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim().Truncate(maxLength); + + // Dates come as yyyyMMdd text ("20280426"), a day on the Dutch calendar. + internal static DateOnly? ParseDate(string? value) => + DateOnly.TryParseExact(value, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) + ? date + : null; + + // "Ja" or "Nee"; anything else ("N.v.t.", missing) says nothing either way. + internal static bool? ParseYesNo(string? value) => value?.Trim() switch + { + "Ja" => true, + "Nee" => false, + _ => null, + }; + + // --------------------------------------------------------------------------- + // Socrata rows: every value is text, whatever the column's type. + // --------------------------------------------------------------------------- + + private sealed class RegistrationRow + { + [JsonPropertyName("merk")] public string? Make { get; init; } + [JsonPropertyName("handelsbenaming")] public string? Model { get; init; } + [JsonPropertyName("vervaldatum_apk")] public string? ApkExpiresOn { get; init; } + [JsonPropertyName("wam_verzekerd")] public string? WamInsured { get; init; } + [JsonPropertyName("openstaande_terugroepactie_indicator")] public string? OpenRecallIndicator { get; init; } + } + + private sealed class RecallStatusRow + { + [JsonPropertyName("referentiecode_rdw")] public string? Code { get; init; } + [JsonPropertyName("code_status")] public string? Status { get; init; } + } + + private sealed class RecallDetailsRow + { + [JsonPropertyName("referentiecode_rdw")] public string? Code { get; init; } + [JsonPropertyName("omschrijving_defect")] public string? Defect { get; init; } + // "materiële gevolgen", with the ë dropped from the column name. + [JsonPropertyName("materi_le_gevolgen")] public string? Risk { get; init; } + [JsonPropertyName("beschrijving_van_het_herstel")] public string? Repair { get; init; } + // ERN (serious), GEM (moderate) or NNB (not yet known). + [JsonPropertyName("risicobeoordeling_rdw")] public string? Severity { get; init; } + } +} diff --git a/src/GarageStack.Data/Services/RdwRefresher.cs b/src/GarageStack.Data/Services/RdwRefresher.cs new file mode 100644 index 00000000..58590796 --- /dev/null +++ b/src/GarageStack.Data/Services/RdwRefresher.cs @@ -0,0 +1,89 @@ +using GarageStack.Core.Configuration; +using GarageStack.Core.Helpers; +using GarageStack.Core.Interfaces; +using GarageStack.Core.Models; +using Microsoft.Extensions.Logging; + +namespace GarageStack.Data.Services; + +/// +/// One RDW lookup for one vehicle, saved: the registration first, then its recall statuses, and +/// the details only of recalls not seen before. The API runs it as soon as a plate is entered and +/// the Worker once a lookup is older than RDW_REFRESH_HOURS, so both write the same row the same way. +/// +public sealed class RdwRefresher( + IRdwClient client, + IRdwRepository repository, + RdwOptions options, + TimeProvider time, + ILogger logger) +{ + public bool Enabled => options.Enabled; + + /// + /// Whether is due a lookup: it has a plate, and the last good answer + /// for that plate is older than the refresh interval (or there is none). + /// + public bool IsDue(Vehicle vehicle, RdwVehicleInfo? info) => + options.Enabled + && vehicle.LicensePlate is not null + && (info is not { FetchedAt: { } fetchedAt } + || info.LicensePlate != vehicle.LicensePlate + || time.GetUtcNow().UtcDateTime - fetchedAt >= options.RefreshInterval); + + /// + /// Asks the RDW about the vehicle's plate now and saves the answer, or the reason there is + /// none. Null, having asked nothing, when lookups are off or the vehicle has no plate. + /// + public async Task RefreshAsync(Vehicle vehicle, CancellationToken ct = default) + { + if (!options.Enabled || vehicle.LicensePlate is not { } plate) return null; + + var existing = await repository.GetAsync(vehicle.Id, ct); + IReadOnlyList known = existing?.LicensePlate == plate ? RdwRecalls.Parse(existing.RecallsJson) : []; + + RdwVehicleInfo info; + try + { + info = await LookUpAsync(vehicle.Id, plate, known, ct); + } + catch (RdwUnavailableException ex) + { + // The plate stays out of the log: it is the one thing about the car that leaves the network. + logger.LogWarning("RDW lookup for vehicle {VehicleId} failed: {Reason}", vehicle.Id, ex.Message); + await repository.SetErrorAsync(vehicle.Id, plate, ex.Message, ct); + return await repository.GetAsync(vehicle.Id, ct); + } + + await repository.SaveAsync(info, ct); + logger.LogInformation("RDW lookup for vehicle {VehicleId}: plate known {Found}, {OpenRecalls} open recall(s)", + vehicle.Id, info.Found, RdwRecalls.Parse(info.RecallsJson).Count(r => r.IsOpen)); + return await repository.GetAsync(vehicle.Id, ct); + } + + private async Task LookUpAsync(int vehicleId, string plate, IReadOnlyList known, CancellationToken ct) + { + var now = time.GetUtcNow().UtcDateTime; + var registration = await client.GetRegistrationAsync(plate, ct); + if (registration is null) + return new RdwVehicleInfo { VehicleId = vehicleId, LicensePlate = plate, Found = false, FetchedAt = now }; + + var statuses = await client.GetRecallStatusesAsync(plate, ct); + var unseen = RdwRecalls.Unseen(statuses, known); + var details = unseen.Count > 0 ? await client.GetRecallDetailsAsync(unseen, ct) : []; + + return new RdwVehicleInfo + { + VehicleId = vehicleId, + LicensePlate = plate, + Found = true, + Make = registration.Make, + Model = registration.Model, + ApkExpiresOn = registration.ApkExpiresOn, + WamInsured = registration.WamInsured, + OpenRecallIndicator = registration.OpenRecallIndicator, + RecallsJson = RdwRecalls.Serialize(RdwRecalls.Merge(known, statuses, details, now)), + FetchedAt = now, + }; + } +} diff --git a/src/GarageStack.Tests/ApkReminderTests.cs b/src/GarageStack.Tests/ApkReminderTests.cs new file mode 100644 index 00000000..7ae8b37d --- /dev/null +++ b/src/GarageStack.Tests/ApkReminderTests.cs @@ -0,0 +1,64 @@ +using GarageStack.Core.Helpers; + +namespace GarageStack.Tests; + +// An APK date is a day on the Dutch calendar, so a reminder counts calendar days in Amsterdam: +// the night the clocks change is as long as any other. +public class ApkReminderTests +{ + [Theory] + [InlineData(31, ApkStage.None)] + [InlineData(30, ApkStage.DueWithinMonth)] + [InlineData(8, ApkStage.DueWithinMonth)] + [InlineData(7, ApkStage.DueWithinWeek)] + [InlineData(1, ApkStage.DueWithinWeek)] + // The expiry date is the last day the car may drive on. + [InlineData(0, ApkStage.DueWithinWeek)] + [InlineData(-1, ApkStage.Expired)] + [InlineData(-400, ApkStage.Expired)] + public void StageOf_Thresholds(int daysLeft, ApkStage expected) + { + Assert.Equal(expected, ApkReminder.StageOf(daysLeft)); + } + + // The clocks go back at 03:00 on 25 October 2026, so that day has 25 hours: a count in 24-hour + // spans would come out an hour off around it. + [Theory] + [InlineData("2026-10-24T21:59:00Z", 8)] // 23:59 on the 24th, summer time + [InlineData("2026-10-24T22:00:00Z", 7)] // midnight on the 25th, summer time + [InlineData("2026-10-25T00:30:00Z", 7)] // 02:30 the first time round + [InlineData("2026-10-25T01:30:00Z", 7)] // 02:30 the second time round + [InlineData("2026-10-25T22:59:00Z", 7)] // 23:59 on the 25th, winter time + [InlineData("2026-10-25T23:00:00Z", 6)] // midnight on the 26th + public void DaysLeft_CountsCalendarDaysAcrossTheAutumnChange(string utcNow, int expected) + { + var expiresOn = new DateOnly(2026, 11, 1); + + Assert.Equal(expected, ApkReminder.DaysLeft(expiresOn, Utc(utcNow))); + } + + // The clocks go forward at 02:00 on 29 March 2026: that day has 23 hours. + [Theory] + [InlineData("2026-03-28T22:59:00Z", 31)] // 23:59 on the 28th, winter time + [InlineData("2026-03-28T23:00:00Z", 30)] // midnight on the 29th + [InlineData("2026-03-29T21:59:00Z", 30)] // 23:59 on the 29th, summer time + [InlineData("2026-03-29T22:00:00Z", 29)] // midnight on the 30th + public void DaysLeft_CountsCalendarDaysAcrossTheSpringChange(string utcNow, int expected) + { + var expiresOn = new DateOnly(2026, 4, 28); + + Assert.Equal(expected, ApkReminder.DaysLeft(expiresOn, Utc(utcNow))); + } + + [Fact] + public void DaysLeft_OnTheLastDay_IsZero_AndTheDayAfter_IsExpired() + { + var expiresOn = new DateOnly(2026, 10, 30); + + Assert.Equal(0, ApkReminder.DaysLeft(expiresOn, Utc("2026-10-30T21:00:00Z"))); + Assert.Equal(ApkStage.Expired, ApkReminder.StageOf(ApkReminder.DaysLeft(expiresOn, Utc("2026-10-30T23:00:00Z")))); + } + + private static DateTime Utc(string instant) => + DateTimeOffset.Parse(instant, System.Globalization.CultureInfo.InvariantCulture).UtcDateTime; +} diff --git a/src/GarageStack.Tests/AuthenticationFlowTests.cs b/src/GarageStack.Tests/AuthenticationFlowTests.cs index 5c9ce87f..5b4b0ea3 100644 --- a/src/GarageStack.Tests/AuthenticationFlowTests.cs +++ b/src/GarageStack.Tests/AuthenticationFlowTests.cs @@ -7,6 +7,7 @@ using GarageStack.Api.Services; using GarageStack.Core.Configuration; using GarageStack.Core.Models; +using GarageStack.Data.Demo; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; @@ -1264,6 +1265,78 @@ public async Task ClimateSchedules_RoundTripThroughTheApi() Assert.Equal(HttpStatusCode.NotFound, deletedAgain.StatusCode); } + // ── RDW ─────────────────────────────────────────────────────────────────── + + // The demo answers for the RDW itself: a plate goes in as typed, comes back as the RDW keys it, + // and is looked up straight away. A read-only token reads it but cannot change it. The demo + // car's plate is put back at the end, since every host in this run shares it. + [Fact] + public async Task Rdw_PlateIsNormalizedAndLookedUpAtOnce_AndTokensOnlyRead() + { + var ct = TestContext.Current.CancellationToken; + await using var factory = CreatePasswordOnlyFactory(); + using var client = CreateClient(factory); + await PasswordLoginAsync(client, ct); + var rdwUrl = $"/api/vehicles/{DemoVin}/rdw"; + var plateUrl = $"/api/vehicles/{DemoVin}/license-plate"; + + var seeded = await client.GetFromJsonAsync(rdwUrl, ct); + try + { + var changed = await client.PutAsJsonAsync(plateUrl, new { licensePlate = " ab-12-cd " }, ct); + var status = await changed.Content.ReadFromJsonAsync(ct); + var invalid = await client.PutAsJsonAsync(plateUrl, new { licensePlate = "AB.12" }, ct); + var cleared = await (await client.PutAsJsonAsync(plateUrl, new { licensePlate = "" }, ct)) + .Content.ReadFromJsonAsync(ct); + + Assert.Equal("ZZ123Z", seeded!.LicensePlate); + Assert.Equal(DemoRdwClient.ApkDaysLeft, seeded.Info!.ApkDaysLeft); + Assert.Single(seeded.Info.Recalls, r => r.Open); + + Assert.Equal(HttpStatusCode.OK, changed.StatusCode); + Assert.Equal("AB12CD", status!.LicensePlate); + Assert.True(status.Info!.Found); + Assert.NotNull(status.Info.FetchedAt); + Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode); + Assert.Equal("rdw.invalidPlate", await ProblemCodeAsync(invalid, ct)); + Assert.Equal(new RdwStatusDto(null, null), cleared); + + var created = await client.PostAsJsonAsync("/api/tokens", new { name = "RDW" }, ct); + var token = (await created.Content.ReadFromJsonAsync(ct)).GetProperty("token").GetString()!; + using var bearer = CreateClient(factory, handleCookies: false); + bearer.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + + var read = await bearer.GetAsync(rdwUrl, ct); + var write = await bearer.PutAsJsonAsync(plateUrl, new { licensePlate = "AB12CD" }, ct); + + Assert.Equal(HttpStatusCode.OK, read.StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, write.StatusCode); + Assert.Equal("token.readOnly", await ProblemCodeAsync(write, ct)); + } + finally + { + await client.PutAsJsonAsync(plateUrl, new { licensePlate = "ZZ123Z" }, ct); + } + } + + // Switched off, the API says so in a way the browser recognises, and asks the RDW nothing. + [Fact] + public async Task Rdw_SwitchedOff_AnswersDisabled() + { + var ct = TestContext.Current.CancellationToken; + await using var factory = CreatePasswordOnlyFactory(s => s["RDW_ENABLED"] = "false"); + using var client = CreateClient(factory); + await PasswordLoginAsync(client, ct); + + var read = await client.GetAsync($"/api/vehicles/{DemoVin}/rdw", ct); + var write = await client.PutAsJsonAsync($"/api/vehicles/{DemoVin}/license-plate", new { licensePlate = "AB12CD" }, ct); + + Assert.Equal(HttpStatusCode.NotFound, read.StatusCode); + Assert.Equal(RdwEndpoints.DisabledCode, await ProblemCodeAsync(read, ct)); + Assert.Equal(HttpStatusCode.NotFound, write.StatusCode); + Assert.Equal(RdwEndpoints.DisabledCode, await ProblemCodeAsync(write, ct)); + } + // ── Helpers ─────────────────────────────────────────────────────────────── private static async Task PasswordLoginAsync(HttpClient client, CancellationToken ct) diff --git a/src/GarageStack.Tests/DeploymentOptionsTests.cs b/src/GarageStack.Tests/DeploymentOptionsTests.cs index da92413f..9774a337 100644 --- a/src/GarageStack.Tests/DeploymentOptionsTests.cs +++ b/src/GarageStack.Tests/DeploymentOptionsTests.cs @@ -114,4 +114,56 @@ public void TelemetryRetention_KeepsAYear_GoesOffAtZero_AndNeverDropsBelowNinety { Assert.Equal(expected, TelemetryRetentionOptions.From(Config(("TelemetryRetention:FullDetailDays", configured))).FullDetailDays); } + + // ── RDW ─────────────────────────────────────────────────────────────────── + + // The plate leaves the network only when the operator asks for it. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("yes")] + [InlineData("false")] + public void Rdw_IsOffUnlessExplicitlyOn(string? configured) + { + Assert.False(RdwOptions.From(Config(("Rdw:Enabled", configured))).Enabled); + } + + [Fact] + public void Rdw_WithBlankSettings_RefreshesDailyWithoutAToken() + { + var rdw = RdwOptions.From(Config(("Rdw:Enabled", "true"), ("Rdw:RefreshHours", ""), ("Rdw:AppToken", " "))); + + Assert.True(rdw.Enabled); + Assert.Equal(TimeSpan.FromHours(24), rdw.RefreshInterval); + Assert.Null(rdw.AppToken); + } + + [Theory] + [InlineData("6", 6)] + [InlineData("0", 1)] + [InlineData("-3", 1)] + [InlineData("often", 24)] + public void Rdw_RefreshHours_IsAnHourAtTheLeast(string configured, int expected) + { + Assert.Equal(expected, RdwOptions.From(Config(("Rdw:RefreshHours", configured))).RefreshHours); + } + + [Fact] + public void Rdw_TakesTheVariables() + { + var configuration = new ConfigurationBuilder() + .AddGarageStackEnvironment(new Dictionary + { + ["RDW_ENABLED"] = "true", + ["RDW_REFRESH_HOURS"] = "12", + ["RDW_APP_TOKEN"] = "app-token", + }) + .Build(); + + var rdw = RdwOptions.From(configuration); + + Assert.True(rdw.Enabled); + Assert.Equal(12, rdw.RefreshHours); + Assert.Equal("app-token", rdw.AppToken); + } } diff --git a/src/GarageStack.Tests/FakeHttp.cs b/src/GarageStack.Tests/FakeHttp.cs index e65d15dd..1fa6fe1b 100644 --- a/src/GarageStack.Tests/FakeHttp.cs +++ b/src/GarageStack.Tests/FakeHttp.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Http.Headers; using System.Text; namespace GarageStack.Tests; @@ -30,6 +31,8 @@ public FakeHttpHandler(params (HttpStatusCode Status, string Body)[] responses) public List RequestBodies { get; } = []; + public List RequestHeaders { get; } = []; + public int CallCount => RequestUris.Count; public string? LastRequestBody => RequestBodies.LastOrDefault(); @@ -38,6 +41,7 @@ protected override async Task SendAsync(HttpRequestMessage { RequestUris.Add(request.RequestUri!.ToString()); RequestBodies.Add(request.Content is null ? "" : await request.Content.ReadAsStringAsync(ct)); + RequestHeaders.Add(request.Headers); var (status, body) = _queued.Count > 0 ? _queued.Dequeue() : _otherwise; return new HttpResponseMessage(status) diff --git a/src/GarageStack.Tests/Fixtures/Rdw/recall-details-00BLS4.json b/src/GarageStack.Tests/Fixtures/Rdw/recall-details-00BLS4.json new file mode 100644 index 00000000..a58837d2 --- /dev/null +++ b/src/GarageStack.Tests/Fixtures/Rdw/recall-details-00BLS4.json @@ -0,0 +1,2 @@ +[{"referentiecode_rdw":"MGP240278","publicatiedatum_rdw":"20241111","meldende_producent_distributeur":"MAN Trucks & Bus B.V.","referentiecode_producent":"7979TW","omschrijving_defect":"Bij overmatige veroudering en verontreiniging van de motorolie kan er motorschade optreden met olielekkage tot gevolg.","categorie_defect":"Motorrijtuigen - motor inclusief brandstof-, smeer- en koelsysteem","materi_le_gevolgen":"De olielekkage kan een motorbrand veroorzaken.","beschrijving_van_het_herstel":"De voertuigeigenaar wordt verzocht een afspraak te maken met een merkdealer. Deze zal het oliefilterdeksel controleren en vervangen.","opmerkingen_rdw":"Geen","datum_aankondiging_producent":"20240902","datum_melding_bij_rdw":"20241107","risicobeoordeling_rdw":"GEM","datum_informeren_eigenaar":"20241115","datum_eigenaren_ge_nformeerd":"20241120","totaal_aantal_voertuigen_terugroepactie":"19673","nationaal_opgegeven_aantal_voertuigen_terugroepactie":"793","publicatiedatum_rdw_dt":"2024-11-11T00:00:00.000","datum_aankondiging_producent_dt":"2024-09-02T00:00:00.000","datum_melding_bij_rdw_dt":"2024-11-07T00:00:00.000","datum_informeren_eigenaar_dt":"2024-11-15T00:00:00.000","datum_eigenaren_ge_nformeerd_dt":"2024-11-20T00:00:00.000","api_terugroep_actie_status":"https://opendata.rdw.nl/resource/t49b-isb7.json","api_terugroep_actie_risico":"https://opendata.rdw.nl/resource/9ihi-jgpf.json","api_terugroep_voertuig_merk_type":"https://opendata.rdw.nl/resource/mu2x-mu5e.json","api_terugroep_infomeren_eigenaar":"https://opendata.rdw.nl/resource/mh8w-8cup.json"} +,{"referentiecode_rdw":"MGP260068","publicatiedatum_rdw":"20260423","meldende_producent_distributeur":"MAN Trucks & Bus B.V.","referentiecode_producent":"8984TR","omschrijving_defect":"De hoofdlagerschalen van de motor hebben mogelijk een productiefout.","categorie_defect":"Motorrijtuigen - motor inclusief brandstof-, smeer- en koelsysteem","materi_le_gevolgen":"Hierdoor kan plotseling motorschade ontstaan wat kan leiden tot brand.","beschrijving_van_het_herstel":"De voertuigeigenaar wordt verzocht contact op te nemen met de merkdealer. Deze zal de onderste hoofdlagerschalen vervangen.","meer_informatie_via_telefoonnummer":"033-2852111","datum_aankondiging_producent":"20260202","datum_melding_bij_rdw":"20260422","risicobeoordeling_rdw":"ERN","datum_informeren_eigenaar":"20260323","datum_eigenaren_ge_nformeerd":"20260323","totaal_aantal_voertuigen_terugroepactie":"9880","nationaal_opgegeven_aantal_voertuigen_terugroepactie":"294","publicatiedatum_rdw_dt":"2026-04-23T00:00:00.000","datum_aankondiging_producent_dt":"2026-02-02T00:00:00.000","datum_melding_bij_rdw_dt":"2026-04-22T00:00:00.000","datum_informeren_eigenaar_dt":"2026-03-23T00:00:00.000","datum_eigenaren_ge_nformeerd_dt":"2026-03-23T00:00:00.000","api_terugroep_actie_status":"https://opendata.rdw.nl/resource/t49b-isb7.json","api_terugroep_actie_risico":"https://opendata.rdw.nl/resource/9ihi-jgpf.json","api_terugroep_voertuig_merk_type":"https://opendata.rdw.nl/resource/mu2x-mu5e.json","api_terugroep_infomeren_eigenaar":"https://opendata.rdw.nl/resource/mh8w-8cup.json"}] diff --git a/src/GarageStack.Tests/Fixtures/Rdw/recall-details-HPK98B.json b/src/GarageStack.Tests/Fixtures/Rdw/recall-details-HPK98B.json new file mode 100644 index 00000000..938b6c9e --- /dev/null +++ b/src/GarageStack.Tests/Fixtures/Rdw/recall-details-HPK98B.json @@ -0,0 +1 @@ +[{"referentiecode_rdw":"MGP230061","publicatiedatum_rdw":"20230307","meldende_producent_distributeur":"Van Mossel MG BV - certificaat terugroepacties","referentiecode_producent":"PD12","omschrijving_defect":"De aardingskabel van de hoogvoltkachel is mogelijk niet goed vastgezet of de isolatiebekleding zit er onbedoeld tussen geklemd.","categorie_defect":"Motorrijtuigen en aanhangwagens - elektrische installatie","materi_le_gevolgen":"Een verhoogde overgangsweerstand waardoor de isolatiebekleding vlam kan vatten.","beschrijving_van_het_herstel":"De voertuigeigenaar wordt verzocht een afspraak te maken met een merkdealer. De bevestiging van de aardingskabel zal worden gecontroleerd en de isolatiebekleding zal worden aangepast.","opmerkingen_rdw":"Geen","datum_aankondiging_producent":"20230214","datum_melding_bij_rdw":"20230222","risicobeoordeling_rdw":"ERN","datum_informeren_eigenaar":"20230305","datum_eigenaren_ge_nformeerd":"20230305","totaal_aantal_voertuigen_terugroepactie":"21066","nationaal_opgegeven_aantal_voertuigen_terugroepactie":"286","publicatiedatum_rdw_dt":"2023-03-07T00:00:00.000","datum_aankondiging_producent_dt":"2023-02-14T00:00:00.000","datum_melding_bij_rdw_dt":"2023-02-22T00:00:00.000","datum_informeren_eigenaar_dt":"2023-03-05T00:00:00.000","datum_eigenaren_ge_nformeerd_dt":"2023-03-05T00:00:00.000","api_terugroep_actie_status":"https://opendata.rdw.nl/resource/t49b-isb7.json","api_terugroep_actie_risico":"https://opendata.rdw.nl/resource/9ihi-jgpf.json","api_terugroep_voertuig_merk_type":"https://opendata.rdw.nl/resource/mu2x-mu5e.json","api_terugroep_infomeren_eigenaar":"https://opendata.rdw.nl/resource/mh8w-8cup.json"}] diff --git a/src/GarageStack.Tests/Fixtures/Rdw/recall-status-00BLS4.json b/src/GarageStack.Tests/Fixtures/Rdw/recall-status-00BLS4.json new file mode 100644 index 00000000..188ee0e1 --- /dev/null +++ b/src/GarageStack.Tests/Fixtures/Rdw/recall-status-00BLS4.json @@ -0,0 +1,3 @@ +[{"kenteken":"00BLS4","referentiecode_rdw":"MGP240278","code_status":"O","status":"Openstaande terugroepactie"} +,{"kenteken":"00BLS4","referentiecode_rdw":"MGP260056","code_status":"P","status":"Producent heeft herstel gemeld"} +,{"kenteken":"00BLS4","referentiecode_rdw":"MGP260068","code_status":"O","status":"Openstaande terugroepactie"}] diff --git a/src/GarageStack.Tests/Fixtures/Rdw/recall-status-HPK98B.json b/src/GarageStack.Tests/Fixtures/Rdw/recall-status-HPK98B.json new file mode 100644 index 00000000..6530507f --- /dev/null +++ b/src/GarageStack.Tests/Fixtures/Rdw/recall-status-HPK98B.json @@ -0,0 +1 @@ +[{"kenteken":"HPK98B","referentiecode_rdw":"MGP230061","code_status":"O","status":"Openstaande terugroepactie"}] diff --git a/src/GarageStack.Tests/Fixtures/Rdw/registration-00BLS4.json b/src/GarageStack.Tests/Fixtures/Rdw/registration-00BLS4.json new file mode 100644 index 00000000..d440a1b2 --- /dev/null +++ b/src/GarageStack.Tests/Fixtures/Rdw/registration-00BLS4.json @@ -0,0 +1 @@ +[{"kenteken":"00BLS4","voertuigsoort":"Bedrijfsauto","merk":"MAN","handelsbenaming":"TGS","vervaldatum_apk":"20251208","inrichting":"opleggertrekker","aantal_zitplaatsen":"2","eerste_kleur":"N.v.t.","tweede_kleur":"N.v.t.","aantal_cilinders":"6","cilinderinhoud":"12419","massa_ledig_voertuig":"7241","toegestane_maximum_massa_voertuig":"19000","massa_rijklaar":"7341","datum_eerste_toelating":"20190212","datum_eerste_tenaamstelling_in_nederland":"20190212","wacht_op_keuren":"Geen verstrekking in Open Data","wam_verzekerd":"Nee","oplegger_geremd":"42659","aantal_wielen":"6","afstand_voorzijde_voertuig_tot_hart_koppeling":"449","lengte":"598","breedte":"250","europese_voertuigcategorie":"N3","technische_max_massa_voertuig":"20500","type":"L.2007.46.001","typegoedkeuringsnummer":"e4*2007/46*0229*32","variant":"06S04ZBBABAG9AA","uitvoering":"K1ACB","volgnummer_wijziging_eu_typegoedkeuring":"0","vermogen_massarijklaar":"0.04","wielbasis":"360","export_indicator":"Ja","openstaande_terugroepactie_indicator":"Ja","vervaldatum_tachograaf":"20261105","taxi_indicator":"Nee","maximum_massa_samenstelling":"50000","tellerstandoordeel":"Niet geregistreerd","code_toelichting_tellerstandoordeel":"NG","tenaamstellen_mogelijk":"Nee","vervaldatum_apk_dt":"2025-12-08T00:00:00.000","datum_eerste_toelating_dt":"2019-02-12T00:00:00.000","datum_eerste_tenaamstelling_in_nederland_dt":"2019-02-12T00:00:00.000","vervaldatum_tachograaf_dt":"2026-11-05T00:00:00.000","hoogte_voertuig":"383","api_gekentekende_voertuigen_assen":"https://opendata.rdw.nl/resource/3huj-srit.json","api_gekentekende_voertuigen_brandstof":"https://opendata.rdw.nl/resource/8ys7-d773.json","api_gekentekende_voertuigen_carrosserie":"https://opendata.rdw.nl/resource/vezc-m2t6.json","api_gekentekende_voertuigen_carrosserie_specifiek":"https://opendata.rdw.nl/resource/jhie-znh9.json","api_gekentekende_voertuigen_voertuigklasse":"https://opendata.rdw.nl/resource/kmfi-hrps.json"}] diff --git a/src/GarageStack.Tests/Fixtures/Rdw/registration-HPK98B.json b/src/GarageStack.Tests/Fixtures/Rdw/registration-HPK98B.json new file mode 100644 index 00000000..1eda7bc1 --- /dev/null +++ b/src/GarageStack.Tests/Fixtures/Rdw/registration-HPK98B.json @@ -0,0 +1 @@ +[{"kenteken":"HPK98B","voertuigsoort":"Personenauto","merk":"MG","handelsbenaming":"MG EHS PLUG-IN HYBRID","vervaldatum_apk":"20280426","datum_tenaamstelling":"20250515","bruto_bpm":"753","inrichting":"hatchback","aantal_zitplaatsen":"5","eerste_kleur":"WIT","tweede_kleur":"Niet geregistreerd","aantal_cilinders":"4","cilinderinhoud":"1490","massa_ledig_voertuig":"1750","toegestane_maximum_massa_voertuig":"2196","massa_rijklaar":"1850","maximum_massa_trekken_ongeremd":"750","maximum_trekken_massa_geremd":"1500","datum_eerste_toelating":"20220426","datum_eerste_tenaamstelling_in_nederland":"20250515","wacht_op_keuren":"Geen verstrekking in Open Data","catalogusprijs":"36990","wam_verzekerd":"Ja","maximale_constructiesnelheid":"190","aantal_deuren":"5","aantal_wielen":"4","lengte":"457","breedte":"188","europese_voertuigcategorie":"M1","technische_max_massa_voertuig":"2196","type":"AS23P-L","typegoedkeuringsnummer":"e5*2018/858*00003*02","variant":"3PAB1OH","uitvoering":"SA5LIH","volgnummer_wijziging_eu_typegoedkeuring":"0","vermogen_massarijklaar":"0.02","wielbasis":"272","export_indicator":"Nee","openstaande_terugroepactie_indicator":"Ja","taxi_indicator":"Nee","maximum_massa_samenstelling":"3696","jaar_laatste_registratie_tellerstand":"2026","tellerstandoordeel":"Geen oordeel","code_toelichting_tellerstandoordeel":"05","tenaamstellen_mogelijk":"Ja","vervaldatum_apk_dt":"2028-04-26T00:00:00.000","datum_tenaamstelling_dt":"2025-05-15T00:00:00.000","datum_eerste_toelating_dt":"2022-04-26T00:00:00.000","datum_eerste_tenaamstelling_in_nederland_dt":"2025-05-15T00:00:00.000","hoogte_voertuig":"169","registratie_datum_goedkeuring_afschrijvingsmoment_bpm":"20250115","registratie_datum_goedkeuring_afschrijvingsmoment_bpm_dt":"2025-01-15T00:00:00.000","api_gekentekende_voertuigen_assen":"https://opendata.rdw.nl/resource/3huj-srit.json","api_gekentekende_voertuigen_brandstof":"https://opendata.rdw.nl/resource/8ys7-d773.json","api_gekentekende_voertuigen_carrosserie":"https://opendata.rdw.nl/resource/vezc-m2t6.json","api_gekentekende_voertuigen_carrosserie_specifiek":"https://opendata.rdw.nl/resource/jhie-znh9.json","api_gekentekende_voertuigen_voertuigklasse":"https://opendata.rdw.nl/resource/kmfi-hrps.json"}] diff --git a/src/GarageStack.Tests/Fixtures/Rdw/registration-unknown.json b/src/GarageStack.Tests/Fixtures/Rdw/registration-unknown.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/src/GarageStack.Tests/Fixtures/Rdw/registration-unknown.json @@ -0,0 +1 @@ +[] diff --git a/src/GarageStack.Tests/GarageStack.Tests.csproj b/src/GarageStack.Tests/GarageStack.Tests.csproj index 7c7d1bbd..38536894 100644 --- a/src/GarageStack.Tests/GarageStack.Tests.csproj +++ b/src/GarageStack.Tests/GarageStack.Tests.csproj @@ -21,6 +21,11 @@ + + + + + diff --git a/src/GarageStack.Tests/LicensePlateTests.cs b/src/GarageStack.Tests/LicensePlateTests.cs new file mode 100644 index 00000000..78d3bc24 --- /dev/null +++ b/src/GarageStack.Tests/LicensePlateTests.cs @@ -0,0 +1,49 @@ +using GarageStack.Core.Helpers; + +namespace GarageStack.Tests; + +// A plate is typed the way it is written on the car; the RDW keys it without the separators. +public class LicensePlateTests +{ + [Theory] + [InlineData("ab-123-c", "AB123C")] + [InlineData("AB 123 C", "AB123C")] + [InlineData(" hpk-98-b ", "HPK98B")] + [InlineData("1-ABC-23", "1ABC23")] + [InlineData("AB123C", "AB123C")] + public void Normalize_UpperCasesAndDropsDashesAndSpaces(string typed, string expected) + { + Assert.Equal(expected, LicensePlate.Normalize(typed)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("- -")] + public void Normalize_NothingLeft_IsNull(string? typed) + { + Assert.Null(LicensePlate.Normalize(typed)); + } + + [Theory] + [InlineData("AB123C")] + [InlineData("1ABC23")] + [InlineData("ABCDEFGH")] + public void IsValid_LettersAndDigits_UpToEight(string plate) + { + Assert.True(LicensePlate.IsValid(plate)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("ABCDEFGHI")] + [InlineData("AB.123C")] + [InlineData("AB'123")] + [InlineData("ÄB123C")] + public void IsValid_AnythingElse_IsRefused(string? plate) + { + Assert.False(LicensePlate.IsValid(plate)); + } +} diff --git a/src/GarageStack.Tests/NotificationCategoriesTests.cs b/src/GarageStack.Tests/NotificationCategoriesTests.cs index 5d3ae16e..50f01e06 100644 --- a/src/GarageStack.Tests/NotificationCategoriesTests.cs +++ b/src/GarageStack.Tests/NotificationCategoriesTests.cs @@ -12,6 +12,10 @@ public class NotificationCategoriesTests [InlineData("gateway-login", "gateway-login")] [InlineData("maintenance-overdue-7", "maintenance")] [InlineData("maintenance-due-soon-12", "maintenance")] + [InlineData("rdw-recall-MGP230061", "rdw")] + [InlineData("rdw-apk-due-30-20261108", "rdw")] + [InlineData("rdw-apk-expired-20261108", "rdw")] + [InlineData("rdw-uninsured", "rdw")] public void GroupOf_KnownCategory_IsTheTypeTheChecklistOffers(string category, string group) { Assert.Equal(group, NotificationCategories.GroupOf(category)); diff --git a/src/GarageStack.Tests/RdwApiClientTests.cs b/src/GarageStack.Tests/RdwApiClientTests.cs new file mode 100644 index 00000000..23f6f9bf --- /dev/null +++ b/src/GarageStack.Tests/RdwApiClientTests.cs @@ -0,0 +1,179 @@ +using System.Net; +using GarageStack.Core.Configuration; +using GarageStack.Core.Models; +using GarageStack.Data.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GarageStack.Tests; + +/// +/// The RDW client against responses recorded from opendata.rdw.nl (Fixtures/Rdw): an MG EHS with +/// one open recall, a truck with two open recalls and one repaired, its APK run out and no +/// insurance on record, and a plate the RDW does not know. The files are the RDW's answers +/// exactly as they came, so the field names are the RDW's and not a guess at them. +/// +public class RdwApiClientTests +{ + private static readonly RdwOptions Enabled = new() { Enabled = true }; + + private static string Fixture(string name) => + File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "Rdw", name)); + + private static RdwApiClient Client(FakeHttpHandler handler, RdwOptions? options = null) => + new(new FakeHttpClientFactory(handler), options ?? Enabled, NullLogger.Instance); + + [Fact] + public async Task Registration_ReadsTheFieldsTheCardShows() + { + var handler = new FakeHttpHandler(Fixture("registration-HPK98B.json")); + + var registration = await Client(handler).GetRegistrationAsync("HPK98B", TestContext.Current.CancellationToken); + + Assert.Equal(new RdwRegistration("MG", "MG EHS PLUG-IN HYBRID", new DateOnly(2028, 4, 26), true, true), registration); + Assert.Equal("https://opendata.rdw.nl/resource/m9d7-ebf2.json?kenteken=HPK98B", handler.RequestUris.Single()); + } + + [Fact] + public async Task Registration_ReadsNoAsFalse() + { + var handler = new FakeHttpHandler(Fixture("registration-00BLS4.json")); + + var registration = await Client(handler).GetRegistrationAsync("00BLS4", TestContext.Current.CancellationToken); + + Assert.Equal(new DateOnly(2025, 12, 8), registration!.ApkExpiresOn); + Assert.False(registration.WamInsured); + Assert.True(registration.OpenRecallIndicator); + } + + // Socrata answers a plate it does not know with an empty array: not found, not an error. + [Fact] + public async Task Registration_UnknownPlate_IsNotFound() + { + var handler = new FakeHttpHandler(Fixture("registration-unknown.json")); + + Assert.Null(await Client(handler).GetRegistrationAsync("ZZ999Z", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task RecallStatuses_ReadOpenAndRepairedAlike() + { + var handler = new FakeHttpHandler(Fixture("recall-status-00BLS4.json")); + + var statuses = await Client(handler).GetRecallStatusesAsync("00BLS4", TestContext.Current.CancellationToken); + + Assert.Equal( + [new("MGP240278", "O"), new("MGP260056", "P"), new("MGP260068", "O")], + statuses); + Assert.Equal("https://opendata.rdw.nl/resource/t49b-isb7.json?kenteken=00BLS4", handler.RequestUris.Single()); + } + + [Fact] + public async Task RecallDetails_ReadDefectRiskRepairAndRating() + { + var handler = new FakeHttpHandler(Fixture("recall-details-HPK98B.json")); + + var details = Assert.Single(await Client(handler).GetRecallDetailsAsync(["MGP230061"], TestContext.Current.CancellationToken)); + + Assert.Equal("MGP230061", details.Code); + Assert.StartsWith("De aardingskabel van de hoogvoltkachel", details.Defect); + Assert.StartsWith("Een verhoogde overgangsweerstand", details.Risk); + Assert.StartsWith("De voertuigeigenaar wordt verzocht een afspraak te maken", details.Repair); + Assert.Equal("ERN", details.Severity); + Assert.Equal( + "https://opendata.rdw.nl/resource/j9yg-7rg9.json?$where=referentiecode_rdw in('MGP230061')", + Uri.UnescapeDataString(handler.RequestUris.Single())); + } + + [Fact] + public async Task RecallDetails_AskForEveryCodeInOneRequest() + { + var handler = new FakeHttpHandler(Fixture("recall-details-00BLS4.json")); + + var details = await Client(handler).GetRecallDetailsAsync(["MGP240278", "MGP260068"], TestContext.Current.CancellationToken); + + Assert.Equal(["MGP240278", "MGP260068"], details.Select(d => d.Code).Order()); + Assert.Equal(["GEM", "ERN"], details.OrderBy(d => d.Code).Select(d => d.Severity)); + Assert.Equal(1, handler.CallCount); + } + + // The codes go into a SoQL filter: one that is not shaped like the RDW's own never gets there. + [Fact] + public async Task RecallDetails_CodeThatIsNotACode_IsNotSent() + { + var handler = new FakeHttpHandler("[]"); + + var details = await Client(handler).GetRecallDetailsAsync(["X') OR (1=1"], TestContext.Current.CancellationToken); + + Assert.Empty(details); + Assert.Equal(0, handler.CallCount); + } + + [Fact] + public async Task AppToken_IsSentWhenConfigured() + { + var handler = new FakeHttpHandler(Fixture("registration-unknown.json")); + + await Client(handler, new RdwOptions { Enabled = true, AppToken = "token-123" }) + .GetRegistrationAsync("ZZ999Z", TestContext.Current.CancellationToken); + + Assert.Equal(["token-123"], handler.RequestHeaders.Single().GetValues("X-App-Token")); + } + + [Fact] + public async Task AppToken_IsLeftOutWithoutOne() + { + var handler = new FakeHttpHandler(Fixture("registration-unknown.json")); + + await Client(handler).GetRegistrationAsync("ZZ999Z", TestContext.Current.CancellationToken); + + Assert.False(handler.RequestHeaders.Single().Contains("X-App-Token")); + } + + // Asked to slow down, the client stops asking for a while rather than hammer the limit. + [Fact] + public async Task Throttled_FailsAndBacksOffWithoutAskingAgain() + { + var ct = TestContext.Current.CancellationToken; + var handler = new FakeHttpHandler("", HttpStatusCode.TooManyRequests); + var client = Client(handler); + + await Assert.ThrowsAsync(() => client.GetRegistrationAsync("HPK98B", ct)); + await Assert.ThrowsAsync(() => client.GetRecallStatusesAsync("HPK98B", ct)); + + Assert.Equal(1, handler.CallCount); + } + + [Fact] + public async Task ServerError_IsUnavailable() + { + var handler = new FakeHttpHandler("oops", HttpStatusCode.InternalServerError); + + var ex = await Assert.ThrowsAsync( + () => Client(handler).GetRegistrationAsync("HPK98B", TestContext.Current.CancellationToken)); + + Assert.Contains("500", ex.Message); + } + + [Fact] + public async Task NotJson_IsUnavailable() + { + var handler = new FakeHttpHandler("maintenance"); + + await Assert.ThrowsAsync( + () => Client(handler).GetRegistrationAsync("HPK98B", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task SwitchedOff_SendsNothing() + { + var ct = TestContext.Current.CancellationToken; + var handler = new FakeHttpHandler(Fixture("registration-HPK98B.json")); + var client = Client(handler, new RdwOptions()); + + await Assert.ThrowsAsync(() => client.GetRegistrationAsync("HPK98B", ct)); + await Assert.ThrowsAsync(() => client.GetRecallStatusesAsync("HPK98B", ct)); + await Assert.ThrowsAsync(() => client.GetRecallDetailsAsync(["MGP230061"], ct)); + + Assert.Equal(0, handler.CallCount); + } +} diff --git a/src/GarageStack.Tests/RdwRecallsTests.cs b/src/GarageStack.Tests/RdwRecallsTests.cs new file mode 100644 index 00000000..0304f484 --- /dev/null +++ b/src/GarageStack.Tests/RdwRecallsTests.cs @@ -0,0 +1,107 @@ +using GarageStack.Core.Helpers; +using GarageStack.Core.Models; + +namespace GarageStack.Tests; + +// What a lookup keeps of the recalls it saw before, which is what keeps a recall from being +// pushed twice. +public class RdwRecallsTests +{ + private static readonly DateTime Earlier = new(2026, 9, 1, 8, 0, 0, DateTimeKind.Utc); + private static readonly DateTime Now = new(2026, 10, 19, 8, 0, 0, DateTimeKind.Utc); + + private static RdwRecallDetails Details(string code) => new(code, $"defect {code}", $"risk {code}", $"repair {code}", "ERN"); + + [Fact] + public void Merge_NewRecall_IsFirstSeenNow_WithItsDetails() + { + var merged = RdwRecalls.Merge([], [new("MGP230061", "O")], [Details("MGP230061")], Now); + + Assert.Equal( + [new RdwRecall("MGP230061", "O", "defect MGP230061", "risk MGP230061", "repair MGP230061", "ERN", Now)], + merged); + } + + [Fact] + public void Merge_KnownRecall_KeepsWhenItWasSeenAndPushed_AndTakesTheNewStatus() + { + var known = new RdwRecall("MGP230061", "O", "defect", "risk", "repair", "ERN", Earlier, NotifiedAt: Earlier); + + var merged = Assert.Single(RdwRecalls.Merge([known], [new("MGP230061", "P")], [], Now)); + + Assert.Equal(known with { Status = "P" }, merged); + } + + [Fact] + public void Merge_RecallTheRdwNoLongerLists_IsDropped() + { + var known = new RdwRecall("MGP230061", "P", null, null, null, null, Earlier); + + Assert.Empty(RdwRecalls.Merge([known], [], [], Now)); + } + + [Fact] + public void Merge_ListsOpenRecallsFirst() + { + var merged = RdwRecalls.Merge( + [], [new("MGP240278", "O"), new("MGP260056", "P"), new("MGP260068", "O")], [], Now); + + Assert.Equal(["MGP240278", "MGP260068", "MGP260056"], merged.Select(r => r.Code)); + } + + // Only a recall not seen before needs its details asked for. + [Fact] + public void Unseen_IsOnlyTheCodesNotKeptYet() + { + var known = new RdwRecall("MGP240278", "O", null, null, null, null, Earlier); + + var unseen = RdwRecalls.Unseen([new("MGP240278", "O"), new("MGP260068", "O"), new("MGP260068", "O")], [known]); + + Assert.Equal(["MGP260068"], unseen); + } + + [Fact] + public void ToNotify_IsTheOpenRecallsNotPushedYet() + { + var recalls = new[] + { + new RdwRecall("A1", "O", null, null, null, null, Earlier), + new RdwRecall("B2", "O", null, null, null, null, Earlier, NotifiedAt: Earlier), + new RdwRecall("C3", "P", null, null, null, null, Earlier), + }; + + Assert.Equal(["A1"], RdwRecalls.ToNotify(recalls).Select(r => r.Code)); + } + + // The whole life of a recall: pushed once while open, and no news when it is repaired. + [Fact] + public void RecallRepairedAfterItsPush_IsNotPushedAgain() + { + var first = RdwRecalls.Merge([], [new("MGP230061", "O")], [Details("MGP230061")], Earlier); + var pushed = first.Select(r => r with { NotifiedAt = Earlier }).ToList(); + + var repaired = RdwRecalls.Merge(pushed, [new("MGP230061", "P")], [], Now); + var openAgain = RdwRecalls.Merge(repaired, [new("MGP230061", "O")], [], Now); + + Assert.Single(RdwRecalls.ToNotify(first)); + Assert.Empty(RdwRecalls.ToNotify(repaired)); + Assert.Empty(RdwRecalls.ToNotify(openAgain)); + } + + [Fact] + public void Serialize_RoundTrips() + { + var recalls = RdwRecalls.Merge([], [new("MGP230061", "O")], [Details("MGP230061")], Now); + + Assert.Equal(recalls, RdwRecalls.Parse(RdwRecalls.Serialize(recalls))); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not json")] + public void Parse_Unreadable_IsNoRecalls(string? json) + { + Assert.Empty(RdwRecalls.Parse(json)); + } +} diff --git a/src/GarageStack.Tests/RdwRefresherTests.cs b/src/GarageStack.Tests/RdwRefresherTests.cs new file mode 100644 index 00000000..7369fe66 --- /dev/null +++ b/src/GarageStack.Tests/RdwRefresherTests.cs @@ -0,0 +1,173 @@ +using GarageStack.Core.Configuration; +using GarageStack.Core.Helpers; +using GarageStack.Core.Models; +using GarageStack.Data.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GarageStack.Tests; + +// One lookup, saved: what is asked, and what is kept when the RDW cannot be asked. +public class RdwRefresherTests +{ + private static readonly DateTimeOffset Start = new(2026, 10, 19, 8, 0, 0, TimeSpan.Zero); + private static readonly RdwOptions Enabled = new() { Enabled = true }; + + [Fact] + public async Task FirstLookup_SavesTheRegistrationAndEveryRecall_WithItsDetails() + { + var ct = TestContext.Current.CancellationToken; + var client = new FakeRdwClient + { + Statuses = [new("MGP240278", "O"), new("MGP260056", "P")], + Details = [new("MGP240278", "defect", "risk", "repair", "GEM"), new("MGP260056", "d", "r", "f", "ERN")], + }; + await using var services = RdwTestServices.Build(client, Enabled, new FixedTimeProvider(Start)); + var vehicle = await RdwTestServices.AddVehicleAsync(services, "HPK98B", ct); + + var info = await RdwTestServices.RefreshAsync(services, vehicle, ct); + + Assert.NotNull(info); + Assert.True(info.Found); + Assert.Equal("HPK98B", info.LicensePlate); + Assert.Equal(new DateOnly(2027, 6, 1), info.ApkExpiresOn); + Assert.Equal(Start.UtcDateTime, info.FetchedAt); + Assert.Null(info.LastError); + var recalls = RdwRecalls.Parse(info.RecallsJson); + Assert.Equal(["MGP240278", "MGP260056"], recalls.Select(r => r.Code)); + Assert.All(recalls, r => Assert.Equal(Start.UtcDateTime, r.FirstSeenAt)); + Assert.Equal("risk", recalls[0].Risk); + Assert.Equal(["MGP240278", "MGP260056"], Assert.Single(client.DetailRequests)); + } + + // The details of a recall do not change; they are only asked for one not seen before. + [Fact] + public async Task NextLookup_AsksDetailsOnlyForANewRecall() + { + var ct = TestContext.Current.CancellationToken; + var time = new FixedTimeProvider(Start); + var client = new FakeRdwClient + { + Statuses = [new("MGP240278", "O")], + Details = [new("MGP240278", "defect", "risk", "repair", "GEM"), new("MGP260068", "d", "r", "f", "ERN")], + }; + await using var services = RdwTestServices.Build(client, Enabled, time); + var vehicle = await RdwTestServices.AddVehicleAsync(services, "00BLS4", ct); + await RdwTestServices.RefreshAsync(services, vehicle, ct); + + time.Advance(TimeSpan.FromDays(1)); + await RdwTestServices.RefreshAsync(services, vehicle, ct); + client.Statuses = [new("MGP240278", "P"), new("MGP260068", "O")]; + var info = await RdwTestServices.RefreshAsync(services, vehicle, ct); + + Assert.Equal(2, client.DetailRequests.Count); + Assert.Equal(["MGP260068"], client.DetailRequests[1]); + var recalls = RdwRecalls.Parse(info!.RecallsJson); + Assert.Equal(Start.UtcDateTime, recalls.Single(r => r.Code == "MGP240278").FirstSeenAt); + Assert.Equal("P", recalls.Single(r => r.Code == "MGP240278").Status); + Assert.Equal(time.GetUtcNow().UtcDateTime, recalls.Single(r => r.Code == "MGP260068").FirstSeenAt); + } + + [Fact] + public async Task UnknownPlate_IsSavedAsNotFound_WithoutAskingForRecalls() + { + var ct = TestContext.Current.CancellationToken; + var client = new FakeRdwClient { Registration = null }; + await using var services = RdwTestServices.Build(client, Enabled, new FixedTimeProvider(Start)); + var vehicle = await RdwTestServices.AddVehicleAsync(services, "ZZ999Z", ct); + + var info = await RdwTestServices.RefreshAsync(services, vehicle, ct); + + Assert.False(info!.Found); + Assert.Null(info.LastError); + Assert.Equal(Start.UtcDateTime, info.FetchedAt); + Assert.Equal(0, client.StatusRequests); + } + + [Fact] + public async Task Failure_KeepsTheLastAnswer_AndSaysWhy() + { + var ct = TestContext.Current.CancellationToken; + var time = new FixedTimeProvider(Start); + var client = new FakeRdwClient(); + await using var services = RdwTestServices.Build(client, Enabled, time); + var vehicle = await RdwTestServices.AddVehicleAsync(services, "HPK98B", ct); + await RdwTestServices.RefreshAsync(services, vehicle, ct); + + time.Advance(TimeSpan.FromDays(1)); + client.Failure = new RdwUnavailableException("The RDW answered 500"); + var info = await RdwTestServices.RefreshAsync(services, vehicle, ct); + + Assert.Equal("The RDW answered 500", info!.LastError); + Assert.Equal(Start.UtcDateTime, info.FetchedAt); + Assert.Equal(new DateOnly(2027, 6, 1), info.ApkExpiresOn); + } + + // A push the Worker marked while a lookup was running survives that lookup's save. + [Fact] + public async Task Save_KeepsARecallMarkedPushedMeanwhile() + { + var ct = TestContext.Current.CancellationToken; + var client = new FakeRdwClient { Statuses = [new("MGP240278", "O")] }; + await using var services = RdwTestServices.Build(client, Enabled, new FixedTimeProvider(Start)); + var vehicle = await RdwTestServices.AddVehicleAsync(services, "HPK98B", ct); + await RdwTestServices.RefreshAsync(services, vehicle, ct); + + await using (var scope = services.CreateAsyncScope()) + { + var rdw = scope.ServiceProvider.GetRequiredService(); + var stale = await rdw.GetAsync(vehicle.Id, ct); + await rdw.MarkRecallsNotifiedAsync(vehicle.Id, ["MGP240278"], Start.UtcDateTime, ct); + await rdw.SaveAsync(stale!, ct); + } + + var recall = Assert.Single(RdwRecalls.Parse((await RdwTestServices.InfoAsync(services, vehicle.Id, ct))!.RecallsJson)); + Assert.Equal(Start.UtcDateTime, recall.NotifiedAt); + } + + [Fact] + public async Task IsDue_OnceTheLookupIsOlderThanTheInterval_OrForAnotherPlate() + { + var ct = TestContext.Current.CancellationToken; + var time = new FixedTimeProvider(Start); + await using var services = RdwTestServices.Build(new FakeRdwClient(), new RdwOptions { Enabled = true, RefreshHours = 24 }, time); + var vehicle = await RdwTestServices.AddVehicleAsync(services, "HPK98B", ct); + var info = await RdwTestServices.RefreshAsync(services, vehicle, ct); + await using var scope = services.CreateAsyncScope(); + var refresher = scope.ServiceProvider.GetRequiredService(); + + var fresh = refresher.IsDue(vehicle, info); + var anotherPlate = refresher.IsDue(new Vehicle { Id = vehicle.Id, LicensePlate = "AB123C" }, info); + time.Advance(TimeSpan.FromHours(23)); + var nearly = refresher.IsDue(vehicle, info); + time.Advance(TimeSpan.FromHours(1)); + var stale = refresher.IsDue(vehicle, info); + + Assert.False(fresh); + Assert.True(anotherPlate); + Assert.False(nearly); + Assert.True(stale); + Assert.True(refresher.IsDue(vehicle, null)); + Assert.False(refresher.IsDue(new Vehicle { Id = vehicle.Id }, null)); + } + + // Switched off, nothing is asked: not by the refresher, not by the real client behind it. + [Fact] + public async Task SwitchedOff_MakesNoHttpCall() + { + var ct = TestContext.Current.CancellationToken; + var handler = new FakeHttpHandler("[]"); + var disabled = new RdwOptions(); + var client = new RdwApiClient(new FakeHttpClientFactory(handler), disabled, NullLogger.Instance); + await using var services = RdwTestServices.Build(client, disabled, new FixedTimeProvider(Start)); + var vehicle = await RdwTestServices.AddVehicleAsync(services, "HPK98B", ct); + + var info = await RdwTestServices.RefreshAsync(services, vehicle, ct); + await using var scope = services.CreateAsyncScope(); + + Assert.Null(info); + Assert.False(scope.ServiceProvider.GetRequiredService().IsDue(vehicle, null)); + Assert.Null(await RdwTestServices.InfoAsync(services, vehicle.Id, ct)); + Assert.Equal(0, handler.CallCount); + } +} diff --git a/src/GarageStack.Tests/RdwSyncServiceTests.cs b/src/GarageStack.Tests/RdwSyncServiceTests.cs new file mode 100644 index 00000000..f6f8d284 --- /dev/null +++ b/src/GarageStack.Tests/RdwSyncServiceTests.cs @@ -0,0 +1,254 @@ +using GarageStack.Core.Configuration; +using GarageStack.Core.Helpers; +using GarageStack.Core.Models; +using GarageStack.Data.Services; +using GarageStack.Worker; +using GarageStack.Worker.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GarageStack.Tests; + +// The Worker's round over the real repositories: when it looks a plate up, and which pushes come +// of what the RDW said. +public class RdwSyncServiceTests +{ + private static readonly IStringLocalizer Strings = WorkerLocalizer.Notifications(); + private static readonly DateTimeOffset Start = new(2026, 10, 19, 8, 0, 0, TimeSpan.Zero); + private static readonly RdwOptions Enabled = new() { Enabled = true }; + + private sealed class OnePass(IServiceScopeFactory scopes, FakePushSender push, RdwOptions options, TimeProvider time) + : RdwSyncService(NullLogger.Instance, scopes, push, options, Strings, time) + { + public Task RunAsync(CancellationToken ct) => RunOnceAsync(ct); + + public Task RunServiceAsync(CancellationToken ct) => ExecuteAsync(ct); + } + + private static OnePass Service(ServiceProvider services, FakePushSender push, RdwOptions options, TimeProvider time) => + new(services.GetRequiredService(), push, options, time); + + [Fact] + public async Task NewOpenRecall_IsPushedOnce() + { + var ct = TestContext.Current.CancellationToken; + var time = new FixedTimeProvider(Start); + var client = new FakeRdwClient + { + Statuses = [new("MGP230061", "O")], + Details = [new("MGP230061", "De aardingskabel zit los.", "Brand", "Vastzetten", "ERN")], + }; + await using var services = RdwTestServices.Build(client, Enabled, time); + await RdwTestServices.AddVehicleAsync(services, "HPK98B", ct); + var push = new FakePushSender(); + var service = Service(services, push, Enabled, time); + + await service.RunAsync(ct); + time.Advance(TimeSpan.FromHours(1)); + await service.RunAsync(ct); + + var sent = Assert.Single(push.Sent); + Assert.Equal("rdw-recall-MGP230061", sent.Category); + Assert.Equal("Open recall", sent.Title); + Assert.Contains("MGP230061", sent.Body); + Assert.Contains("De aardingskabel zit los.", sent.Body); + } + + // Repaired is no news, and a recall the API's lookup found is pushed by the Worker's next round. + [Fact] + public async Task RecallRepaired_IsNotPushedAgain_ButASecondRecallIs() + { + var ct = TestContext.Current.CancellationToken; + var time = new FixedTimeProvider(Start); + var client = new FakeRdwClient { Statuses = [new("MGP240278", "O")] }; + await using var services = RdwTestServices.Build(client, new RdwOptions { Enabled = true, RefreshHours = 24 }, time); + var vehicle = await RdwTestServices.AddVehicleAsync(services, "00BLS4", ct); + var push = new FakePushSender(); + var service = Service(services, push, Enabled, time); + + // The API looks the plate up the moment it is entered; the Worker pushes what it found. + await RdwTestServices.RefreshAsync(services, vehicle, ct); + await service.RunAsync(ct); + + time.Advance(TimeSpan.FromHours(25)); + client.Statuses = [new("MGP240278", "P"), new("MGP260068", "O")]; + await service.RunAsync(ct); + time.Advance(TimeSpan.FromHours(25)); + await service.RunAsync(ct); + + Assert.Equal(["rdw-recall-MGP240278", "rdw-recall-MGP260068"], push.Sent.Select(s => s.Category)); + Assert.Equal(3, client.StatusRequests); + var recalls = RdwRecalls.Parse((await RdwTestServices.InfoAsync(services, vehicle.Id, ct))!.RecallsJson); + Assert.All(recalls, r => Assert.NotNull(r.NotifiedAt)); + } + + // A recall first seen already repaired was never something to act on. + [Fact] + public async Task RecallFirstSeenRepaired_IsNotPushed() + { + var ct = TestContext.Current.CancellationToken; + var time = new FixedTimeProvider(Start); + var client = new FakeRdwClient { Statuses = [new("MGP200235", "P")] }; + await using var services = RdwTestServices.Build(client, Enabled, time); + await RdwTestServices.AddVehicleAsync(services, "00BPJ3", ct); + var push = new FakePushSender(); + + await Service(services, push, Enabled, time).RunAsync(ct); + + Assert.Empty(push.Sent); + } + + [Fact] + public async Task Apk_IsPushedAMonthAndAWeekAhead_AndOnceItRanOut() + { + var ct = TestContext.Current.CancellationToken; + var time = new FixedTimeProvider(Start); + // 40 days from 19 October. + var client = new FakeRdwClient { Registration = new("MG", "MG4", new DateOnly(2026, 11, 28), true, false) }; + await using var services = RdwTestServices.Build(client, Enabled, time); + await RdwTestServices.AddVehicleAsync(services, "AB123C", ct); + var push = new FakePushSender(); + var service = Service(services, push, Enabled, time); + + // A pass a day for seven weeks. + for (var day = 0; day < 49; day++) + { + await service.RunAsync(ct); + time.Advance(TimeSpan.FromDays(1)); + } + + Assert.Equal( + ["rdw-apk-due-30-20261128", "rdw-apk-due-7-20261128", "rdw-apk-expired-20261128"], + push.Sent.Select(s => s.Category)); + Assert.Equal("The APK runs out on 28 November 2026, in 30 days.", push.Sent[0].Body); + Assert.Equal("The APK runs out on 28 November 2026, in 7 days.", push.Sent[1].Body); + Assert.Equal("APK expired", push.Sent[2].Title); + } + + // A renewed APK has a new date, and so its own reminders. + [Fact] + public async Task Apk_Renewed_GetsItsOwnReminders() + { + var due = new RdwVehicleInfo { Found = true, ApkExpiresOn = new DateOnly(2026, 11, 8) }; + var renewed = new RdwVehicleInfo { Found = true, ApkExpiresOn = new DateOnly(2027, 11, 8) }; + + var first = RdwSyncService.BuildAlerts(due, Start.UtcDateTime, Strings).Single(); + var nextYear = RdwSyncService.BuildAlerts(renewed, new DateTime(2027, 10, 19, 8, 0, 0, DateTimeKind.Utc), Strings).Single(); + + Assert.Equal("rdw-apk-due-30-20261108", first.Category); + Assert.Equal("rdw-apk-due-30-20271108", nextYear.Category); + } + + [Theory] + [InlineData(0, "Today, 19 October 2026, is the APK's last day.")] + [InlineData(1, "The APK runs out tomorrow, 20 October 2026.")] + [InlineData(3, "The APK runs out on 22 October 2026, in 3 days.")] + public void ApkDueBody_SaysWhenInWords(int daysLeft, string expected) + { + var info = new RdwVehicleInfo { Found = true, ApkExpiresOn = new DateOnly(2026, 10, 19).AddDays(daysLeft) }; + + Assert.Equal(expected, RdwSyncService.BuildAlerts(info, Start.UtcDateTime, Strings).Single().Body); + } + + [Fact] + public void NotInsured_IsAnAlert_AndUnknownIsNot() + { + var uninsured = new RdwVehicleInfo { Found = true, WamInsured = false }; + var unknown = new RdwVehicleInfo { Found = true, WamInsured = null }; + + Assert.Equal(NotificationCategories.RdwUninsured, RdwSyncService.BuildAlerts(uninsured, Start.UtcDateTime, Strings).Single().Category); + Assert.Empty(RdwSyncService.BuildAlerts(unknown, Start.UtcDateTime, Strings)); + } + + [Fact] + public async Task NotInsured_IsPushed_AndHeldBackOnTheNextRound() + { + var ct = TestContext.Current.CancellationToken; + var time = new FixedTimeProvider(Start); + var client = new FakeRdwClient { Registration = new("MAN", "TGS", null, false, false) }; + await using var services = RdwTestServices.Build(client, Enabled, time); + await RdwTestServices.AddVehicleAsync(services, "00BLS4", ct); + var push = new FakePushSender(); + var service = Service(services, push, Enabled, time); + + await service.RunAsync(ct); + time.Advance(TimeSpan.FromHours(1)); + await service.RunAsync(ct); + + Assert.Equal(["rdw-uninsured"], push.Sent.Select(s => s.Category)); + Assert.Equal("No insurance on record", push.Sent[0].Title); + } + + // An APK renewed while the RDW could not be reached must not be reported as running out. + [Fact] + public async Task OldLookup_IsNotPushedFrom() + { + var ct = TestContext.Current.CancellationToken; + var time = new FixedTimeProvider(Start); + var client = new FakeRdwClient { Registration = new("MG", "MG4", new DateOnly(2026, 11, 28), true, false) }; + await using var services = RdwTestServices.Build(client, Enabled, time); + var vehicle = await RdwTestServices.AddVehicleAsync(services, "AB123C", ct); + await RdwTestServices.RefreshAsync(services, vehicle, ct); + var push = new FakePushSender(); + + time.Advance(TimeSpan.FromDays(20)); + client.Failure = new RdwUnavailableException("The RDW could not be reached"); + await Service(services, push, Enabled, time).RunAsync(ct); + + Assert.Empty(push.Sent); + } + + [Fact] + public async Task UnknownPlate_IsNotPushedAbout() + { + var ct = TestContext.Current.CancellationToken; + var time = new FixedTimeProvider(Start); + await using var services = RdwTestServices.Build(new FakeRdwClient { Registration = null }, Enabled, time); + await RdwTestServices.AddVehicleAsync(services, "ZZ999Z", ct); + var push = new FakePushSender(); + + await Service(services, push, Enabled, time).RunAsync(ct); + + Assert.Empty(push.Sent); + } + + [Fact] + public async Task VehicleWithoutAPlate_IsNotLookedUp() + { + var ct = TestContext.Current.CancellationToken; + var time = new FixedTimeProvider(Start); + var client = new FakeRdwClient(); + await using var services = RdwTestServices.Build(client, Enabled, time); + await RdwTestServices.AddVehicleAsync(services, null, ct); + + await Service(services, new FakePushSender(), Enabled, time).RunAsync(ct); + + Assert.Equal(0, client.RegistrationRequests); + } + + // Off, the job stops before its first pass: no database, no RDW, no push. + [Fact] + public async Task SwitchedOff_StopsAtOnce_AndMakesNoHttpCall() + { + var ct = TestContext.Current.CancellationToken; + var handler = new FakeHttpHandler("[]"); + var disabled = new RdwOptions(); + var client = new RdwApiClient(new FakeHttpClientFactory(handler), disabled, NullLogger.Instance); + var time = new FixedTimeProvider(Start); + await using var services = RdwTestServices.Build(client, disabled, time); + await RdwTestServices.AddVehicleAsync(services, "HPK98B", ct); + var push = new FakePushSender(); + var scopes = new FakeServiceScopeFactory(); + + var stopped = new OnePass(scopes, push, disabled, time).RunServiceAsync(ct); + await stopped.WaitAsync(TimeSpan.FromSeconds(5), ct); + // Even a pass forced through finds nothing to do. + await Service(services, push, disabled, time).RunAsync(ct); + + Assert.True(stopped.IsCompletedSuccessfully); + Assert.Equal(0, scopes.CreatedScopes); + Assert.Equal(0, handler.CallCount); + Assert.Empty(push.Sent); + } +} diff --git a/src/GarageStack.Tests/RdwTestFakes.cs b/src/GarageStack.Tests/RdwTestFakes.cs new file mode 100644 index 00000000..9fb69acc --- /dev/null +++ b/src/GarageStack.Tests/RdwTestFakes.cs @@ -0,0 +1,95 @@ +using GarageStack.Core.Configuration; +using GarageStack.Core.Interfaces; +using GarageStack.Core.Models; +using GarageStack.Data; +using GarageStack.Data.Repositories; +using GarageStack.Data.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.DependencyInjection; + +namespace GarageStack.Tests; + +/// The RDW as a test wants it, counting what it is asked. +internal sealed class FakeRdwClient : IRdwClient +{ + public RdwRegistration? Registration { get; set; } = new("MG", "MG ZS EV", new DateOnly(2027, 6, 1), true, false); + + public List Statuses { get; set; } = []; + + public List Details { get; set; } = []; + + /// Thrown by every call while set. + public RdwUnavailableException? Failure { get; set; } + + public int RegistrationRequests { get; private set; } + + public int StatusRequests { get; private set; } + + public List> DetailRequests { get; } = []; + + public Task GetRegistrationAsync(string plate, CancellationToken ct = default) + { + RegistrationRequests++; + return Failure is { } failure ? throw failure : Task.FromResult(Registration); + } + + public Task> GetRecallStatusesAsync(string plate, CancellationToken ct = default) + { + StatusRequests++; + return Failure is { } failure ? throw failure : Task.FromResult>(Statuses.ToList()); + } + + public Task> GetRecallDetailsAsync(IReadOnlyCollection codes, CancellationToken ct = default) + { + DetailRequests.Add(codes); + return Failure is { } failure + ? throw failure + : Task.FromResult>(Details.Where(d => codes.Contains(d.Code)).ToList()); + } +} + +internal static class RdwTestServices +{ + public const string Vin = "LSJW74098PZ000001"; + + /// The real repositories and refresher over an in-memory database, asking . + public static ServiceProvider Build(IRdwClient client, RdwOptions options, TimeProvider time) + { + var root = new InMemoryDatabaseRoot(); + var name = Guid.NewGuid().ToString(); + return new ServiceCollection() + .AddLogging() + .AddDbContext(o => o.UseInMemoryDatabase(name, root)) + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddSingleton(client) + .AddSingleton(options) + .AddSingleton(time) + .BuildServiceProvider(); + } + + public static async Task AddVehicleAsync(ServiceProvider services, string? plate, CancellationToken ct) + { + await using var scope = services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var vehicle = new Vehicle { Vin = Vin, LicensePlate = plate }; + db.Vehicles.Add(vehicle); + await db.SaveChangesAsync(ct); + return vehicle; + } + + public static async Task InfoAsync(ServiceProvider services, int vehicleId, CancellationToken ct) + { + await using var scope = services.CreateAsyncScope(); + return await scope.ServiceProvider.GetRequiredService().GetAsync(vehicleId, ct); + } + + public static async Task RefreshAsync(ServiceProvider services, Vehicle vehicle, CancellationToken ct) + { + await using var scope = services.CreateAsyncScope(); + return await scope.ServiceProvider.GetRequiredService().RefreshAsync(vehicle, ct); + } +} diff --git a/src/GarageStack.Worker/Program.cs b/src/GarageStack.Worker/Program.cs index 627b7656..1a93a6bc 100644 --- a/src/GarageStack.Worker/Program.cs +++ b/src/GarageStack.Worker/Program.cs @@ -39,6 +39,8 @@ builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); + // The Dutch registration's APK, recalls and insurance; stops at once unless RDW_ENABLED is set. + builder.Services.AddHostedService(); // Climate schedules: the Worker decides when, the Api sends the commands (see VehicleCommandClient). builder.Services.AddSingleton(TimeProvider.System); diff --git a/src/GarageStack.Worker/Resources/NotificationStrings.nl.resx b/src/GarageStack.Worker/Resources/NotificationStrings.nl.resx index 3f3b2a70..722e2aff 100644 --- a/src/GarageStack.Worker/Resources/NotificationStrings.nl.resx +++ b/src/GarageStack.Worker/Resources/NotificationStrings.nl.resx @@ -231,4 +231,37 @@ de spanning van de laadpaal past niet bij de auto + + Openstaande terugroepactie + + + De RDW meldt een openstaande terugroepactie ({0}): {1} + + + De RDW meldt een openstaande terugroepactie ({0}). Een dealer van het merk weet waar die over gaat. + + + APK verloopt binnenkort + + + De APK verloopt op {0}, over {1} dagen. + + + De APK verloopt morgen, {0}. + + + Vandaag, {0}, is de laatste dag van de APK. + + + APK verlopen + + + De APK is op {0} verlopen. Zonder APK mag de auto niet de openbare weg op. + + + Geen verzekering geregistreerd + + + De RDW heeft voor de auto geen WAM-verzekering geregistreerd. Een auto op de openbare weg moet verzekerd zijn. + diff --git a/src/GarageStack.Worker/Resources/NotificationStrings.resx b/src/GarageStack.Worker/Resources/NotificationStrings.resx index 941f4609..ea363956 100644 --- a/src/GarageStack.Worker/Resources/NotificationStrings.resx +++ b/src/GarageStack.Worker/Resources/NotificationStrings.resx @@ -231,4 +231,37 @@ the charger's voltage does not match the car + + Open recall + + + The RDW lists an open recall ({0}): {1} + + + The RDW lists an open recall ({0}). A dealer of the make can tell you what it is about. + + + APK due soon + + + The APK runs out on {0}, in {1} days. + + + The APK runs out tomorrow, {0}. + + + Today, {0}, is the APK's last day. + + + APK expired + + + The APK ran out on {0}. Without one the car may not be driven on public roads. + + + No insurance on record + + + The RDW has no WAM insurance on record for the car. A car on the public road must be insured. + diff --git a/src/GarageStack.Worker/Services/RdwSyncService.cs b/src/GarageStack.Worker/Services/RdwSyncService.cs new file mode 100644 index 00000000..1d5a8cec --- /dev/null +++ b/src/GarageStack.Worker/Services/RdwSyncService.cs @@ -0,0 +1,146 @@ +using System.Globalization; +using GarageStack.Core.Configuration; +using GarageStack.Core.Helpers; +using GarageStack.Core.Interfaces; +using GarageStack.Core.Models; +using GarageStack.Data.Services; +using Microsoft.Extensions.Localization; + +namespace GarageStack.Worker.Services; + +/// +/// Keeps each car's RDW lookup current and says when something in it needs attention: a new open +/// recall (once per recall), the APK a month and a week ahead and once it has run out, and no +/// insurance on record. With RDW_ENABLED unset it stops at once and asks nothing. +/// +public class RdwSyncService( + ILogger logger, + IServiceScopeFactory scopeFactory, + IPushSender pushSender, + RdwOptions options, + IStringLocalizer strings, + TimeProvider time) : PeriodicBackgroundService(logger) +{ + // Each APK category names its expiry date, so this only spaces out the reminders that repeat: + // an expired APK, and a car without insurance. A month is also as far back as the history of a + // notification the user deleted reaches (housekeeping removes those at 30 days). + private readonly NotificationCooldownGate _cooldownGate = new(TimeSpan.FromDays(30)); + + protected override string Name => "RDW sync"; + + // Out of the way of the start-up rush; the API looks a plate up itself as soon as it is entered. + protected override TimeSpan InitialDelay => TimeSpan.FromMinutes(5); + + // A pass that is not due a lookup only reads a row per car, so looking often costs nothing and + // a recall the API's lookup found is pushed within the hour. + protected override TimeSpan Interval => TimeSpan.FromHours(1); + + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + if (options.Enabled) return base.ExecuteAsync(stoppingToken); + + logger.LogInformation("RDW lookups are off (RDW_ENABLED); the RDW sync does not run"); + return Task.CompletedTask; + } + + protected override async Task RunOnceAsync(CancellationToken ct) + { + if (!options.Enabled) return; + + using var scope = scopeFactory.CreateScope(); + var vehicles = await scope.ServiceProvider.GetRequiredService().GetAllAsync(ct); + var rdw = scope.ServiceProvider.GetRequiredService(); + var refresher = scope.ServiceProvider.GetRequiredService(); + var notifications = scope.ServiceProvider.GetRequiredService(); + + var lookups = (await rdw.ListAsync(ct)).ToDictionary(i => i.VehicleId); + + foreach (var vehicle in vehicles) + { + if (vehicle.LicensePlate is null) continue; + + var info = lookups.GetValueOrDefault(vehicle.Id); + if (refresher.IsDue(vehicle, info)) + info = await refresher.RefreshAsync(vehicle, ct); + + if (info is not { Found: true, FetchedAt: { } fetchedAt } || info.LicensePlate != vehicle.LicensePlate) continue; + + // Only what the RDW said lately: an APK renewed while the RDW could not be reached + // must not be reported as running out. + var now = time.GetUtcNow().UtcDateTime; + if (now - fetchedAt > 2 * options.RefreshInterval) continue; + + await NotifyRecallsAsync(vehicle, info, rdw, now, ct); + foreach (var alert in BuildAlerts(info, now, strings)) + { + if (!await _cooldownGate.ShouldNotifyAsync(vehicle.Vin, vehicle.Id, alert.Category, notifications, ct)) continue; + + await pushSender.SendToAllAsync(alert.Title, alert.Body, ct, alert.Category, vehicle.Id); + logger.LogInformation("RDW push sent: {Vin}/{Category}", LogRedaction.Vin(vehicle.Vin), alert.Category); + } + } + } + + // One push per open recall, ever: the recall is marked the moment it went out, and a recall + // the RDW later reports as repaired, or open again, was marked already. + private async Task NotifyRecallsAsync(Vehicle vehicle, RdwVehicleInfo info, IRdwRepository rdw, DateTime now, CancellationToken ct) + { + foreach (var recall in RdwRecalls.ToNotify(RdwRecalls.Parse(info.RecallsJson))) + { + var alert = BuildRecallAlert(recall, strings); + await pushSender.SendToAllAsync(alert.Title, alert.Body, ct, alert.Category, vehicle.Id); + await rdw.MarkRecallsNotifiedAsync(vehicle.Id, [recall.Code], now, ct); + logger.LogInformation("RDW push sent: {Vin}/{Category}", LogRedaction.Vin(vehicle.Vin), alert.Category); + } + } + + internal readonly record struct RdwAlert(string Category, string Title, string Body); + + internal static RdwAlert BuildRecallAlert(RdwRecall recall, IStringLocalizer strings) => + new( + NotificationCategories.RdwRecall(recall.Code), + strings["RdwRecallTitle"], + recall.Defect is { } defect + ? strings["RdwRecallBody", recall.Code, defect] + : strings["RdwRecallBodyNoDetails", recall.Code]); + + /// The APK and insurance alerts the lookup calls for at , before any cooldown. + internal static IReadOnlyList BuildAlerts(RdwVehicleInfo info, DateTime utcNow, IStringLocalizer strings) + { + var alerts = new List(); + + if (info.ApkExpiresOn is { } expiresOn) + { + var daysLeft = ApkReminder.DaysLeft(expiresOn, utcNow); + // In the notification's language, as its words are: "30 oktober 2026" in Dutch. + var date = expiresOn.ToString("d MMMM yyyy", CultureInfo.CurrentUICulture); + switch (ApkReminder.StageOf(daysLeft)) + { + case ApkStage.Expired: + alerts.Add(new(NotificationCategories.RdwApkExpired(expiresOn), + strings["RdwApkExpiredTitle"], strings["RdwApkExpiredBody", date])); + break; + case ApkStage.DueWithinWeek: + alerts.Add(new(NotificationCategories.RdwApkDue(ApkReminder.WeekNoticeDays, expiresOn), + strings["RdwApkDueTitle"], DueBody(daysLeft, date, strings))); + break; + case ApkStage.DueWithinMonth: + alerts.Add(new(NotificationCategories.RdwApkDue(ApkReminder.MonthNoticeDays, expiresOn), + strings["RdwApkDueTitle"], DueBody(daysLeft, date, strings))); + break; + } + } + + if (info.WamInsured == false) + alerts.Add(new(NotificationCategories.RdwUninsured, strings["RdwUninsuredTitle"], strings["RdwUninsuredBody"])); + + return alerts; + } + + private static string DueBody(int daysLeft, string date, IStringLocalizer strings) => daysLeft switch + { + 0 => strings["RdwApkDueTodayBody", date], + 1 => strings["RdwApkDueTomorrowBody", date], + _ => strings["RdwApkDueBody", date, daysLeft], + }; +} diff --git a/unraid/garagestack.xml b/unraid/garagestack.xml index fbd7a1bc..03252b39 100644 --- a/unraid/garagestack.xml +++ b/unraid/garagestack.xml @@ -449,6 +449,39 @@ Required="false" Mask="true"/> + false + + 24 + + + Date: Mon, 19 Oct 2026 08:31:18 +0000 Subject: [PATCH 2/4] Show the RDW lookup on the dashboard and take the plate in Settings - An "APK & recalls" card: the days the APK has left and the open recalls on the card; the APK date, the WAM insurance, each open recall with its risk and repair, the repaired ones and when the RDW was asked in its dialog. It shows only where the deployment looks plates up and a plate is set. - A licence plate field under Settings, and an "rdw" notification type, both only where the API does not answer rdw.disabled. - Demo mode leaves the RDW lookup and its notifications out when RDW_ENABLED=false. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUS3qyf8owf2McmxrZB6zu --- frontend/src/assets/dashboard.css | 63 ++++++ frontend/src/assets/settings.css | 12 +- frontend/src/cards/__tests__/registry.spec.ts | 8 +- frontend/src/cards/registry.ts | 4 + frontend/src/cards/useCardData.ts | 3 + .../src/components/DashboardCardContent.vue | 4 + .../src/components/ExpandableStatusCard.vue | 3 + frontend/src/components/NotificationPanel.vue | 1 + frontend/src/components/RdwCard.vue | 190 ++++++++++++++++++ .../src/components/__tests__/RdwCard.spec.ts | 139 +++++++++++++ .../settings/LicensePlateSetting.vue | 81 ++++++++ .../settings/NotificationTypeSettings.vue | 10 +- .../src/components/settings/SettingsModal.vue | 22 +- .../NotificationTypeSettings.spec.ts | 17 ++ frontend/src/locales/en.json | 53 +++++ frontend/src/locales/nl.json | 53 +++++ frontend/src/plugins/icons.ts | 2 + frontend/src/services/rdwApi.ts | 50 +++++ frontend/src/stores/__tests__/rdw.spec.ts | 93 +++++++++ frontend/src/stores/rdw.ts | 57 ++++++ frontend/src/stores/settingsDashboard.ts | 25 ++- .../__tests__/notificationCategories.spec.ts | 6 + frontend/src/utils/notificationCategories.ts | 5 +- frontend/src/views/DashboardView.vue | 19 +- src/GarageStack.Api/Program.cs | 2 +- src/GarageStack.Data/Demo/DemoSeeder.cs | 7 +- 26 files changed, 908 insertions(+), 21 deletions(-) create mode 100644 frontend/src/components/RdwCard.vue create mode 100644 frontend/src/components/__tests__/RdwCard.spec.ts create mode 100644 frontend/src/components/settings/LicensePlateSetting.vue create mode 100644 frontend/src/services/rdwApi.ts create mode 100644 frontend/src/stores/__tests__/rdw.spec.ts create mode 100644 frontend/src/stores/rdw.ts 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..72587feb 100644 --- a/frontend/src/assets/settings.css +++ b/frontend/src/assets/settings.css @@ -186,17 +186,25 @@ /* ── 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; } -.api-token-form .form-control { +.api-token-form .form-control, +.license-plate-form .form-control { flex: 1; min-width: 0; } +/* Written the way it is on the plate; what is saved is upper case anyway. */ +.license-plate-form__input { + text-transform: uppercase; + letter-spacing: 0.05em; +} + .api-token-row { display: flex; flex: 1; 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..a8d0fc90 --- /dev/null +++ b/frontend/src/components/RdwCard.vue @@ -0,0 +1,190 @@ + + + diff --git a/frontend/src/components/__tests__/RdwCard.spec.ts b/frontend/src/components/__tests__/RdwCard.spec.ts new file mode 100644 index 00000000..f16f1f6a --- /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('ZZ999Z') + }) + + 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 HPK98B') + }) + + 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/LicensePlateSetting.vue b/frontend/src/components/settings/LicensePlateSetting.vue new file mode 100644 index 00000000..f9f0e638 --- /dev/null +++ b/frontend/src/components/settings/LicensePlateSetting.vue @@ -0,0 +1,81 @@ + + + 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 @@ + + + + diff --git a/frontend/src/components/settings/LicensePlateSetting.vue b/frontend/src/components/settings/LicensePlateSetting.vue index f9f0e638..e3c630aa 100644 --- a/frontend/src/components/settings/LicensePlateSetting.vue +++ b/frontend/src/components/settings/LicensePlateSetting.vue @@ -3,8 +3,9 @@ import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useRdwStore } from '@/stores/rdw' import { useVehicleStore } from '@/stores/vehicle' -import { LICENSE_PLATE_INPUT_MAX_LENGTH } from '@/services/rdwApi' import { useErrorMessage } from '@/composables/useErrorMessage' +import { formatPlate, normalizePlate } from '@/utils/licensePlate' +import LicensePlateInput from './LicensePlateInput.vue' // The car's Dutch plate, kept on the vehicle (the Worker looks it up) rather than in the // browser's settings. Saving looks it up at once, so the dashboard card fills straight away. @@ -13,7 +14,8 @@ const errorMessage = useErrorMessage() const rdw = useRdwStore() const vehicleStore = useVehicleStore() -const saved = computed(() => rdw.status?.licensePlate ?? '') +// As written on the car, dashes and all; the API stores it without them. +const saved = computed(() => formatPlate(rdw.status?.licensePlate ?? '')) const plate = ref(saved.value) const busy = ref(false) const error = ref(null) @@ -24,9 +26,7 @@ watch(saved, (value, previous) => { if (plate.value === previous) plate.value = value }) -// The API keys a plate without its dashes and spaces, as the RDW does. -const normalized = computed(() => plate.value.replace(/[\s-]/g, '').toUpperCase()) -const changed = computed(() => normalized.value !== saved.value) +const changed = computed(() => normalizePlate(plate.value) !== normalizePlate(saved.value)) async function save(value: string) { const vin = vehicleStore.activeVin @@ -36,7 +36,7 @@ async function save(value: string) { busy.value = true try { await rdw.setLicensePlate(vin, value) - plate.value = rdw.status?.licensePlate ?? '' + plate.value = saved.value done.value = plate.value ? t('settings.licensePlate.saved') : t('settings.licensePlate.cleared') } catch (e) { error.value = errorMessage(e) @@ -51,15 +51,10 @@ async function save(value: string) {

{{ t('settings.licensePlate.desc') }}

-