Skip to content

feat: Dutch registration lookup at the RDW (APK, recalls, insurance) - #518

Merged
joszz merged 4 commits into
mainfrom
claude/gallant-euler-4d7ks9
Oct 19, 2026
Merged

joszz merged 4 commits into
mainfrom
claude/gallant-euler-4d7ks9

Conversation

@joszz

@joszz joszz commented Oct 19, 2026

Copy link
Copy Markdown
Owner

Summary

For a car with a Dutch licence plate, GarageStack now shows what the RDW has on record: when the APK runs out, open recalls, and whether the car has WAM insurance. The data comes from the RDW's open data and drives a new dashboard card and new notifications.

It is off by default (RDW_ENABLED=false). While off, nothing is sent and the plate field, the card and the notification type do not appear. While on, the plate is the only thing that leaves the network. No API key is needed.

Backend

  • Lookup. RdwApiClient queries three Socrata datasets by plate: the registration (m9d7-ebf2), the recall statuses (t49b-isb7), and recall details (j9yg-7rg9) only for codes it has not seen yet. It goes through the shared UpstreamRateGate and refuses to send anything while the feature is off. A plate the RDW does not know is saved as "not found", not as an error. A failed lookup keeps the last good answer and records the error in LastError.

  • Storage. Migration AddRdwLookups adds Vehicle.LicensePlate (stored without dashes) and an RdwVehicleInfos table, one row per vehicle, with the recalls in a JSON column. No index drops or raw SQL.

  • API. PUT /api/vehicles/{vin}/license-plate saves the plate and looks it up immediately. GET /api/vehicles/{vin}/rdw returns the lookup. Both return 404 rdw.disabled while the feature is off.

  • Worker. RdwSyncService refreshes lookups older than RDW_REFRESH_HOURS (default 24) and sends the notifications:

    • an open recall, once per recall (a recall that later shows as repaired sends nothing)
    • the APK 30 days and 7 days ahead, once per APK date, so a renewed APK starts over
    • an expired APK, at most once a month
    • no WAM insurance on record, at most once a month

    Days are counted between calendar dates in Amsterdam. Nothing is sent from a lookup older than two refresh intervals, so an APK renewed while the RDW was unreachable is not reported as running out.

  • Config. RDW_ENABLED, RDW_REFRESH_HOURS and RDW_APP_TOKEN (optional Socrata token for a higher rate limit) are in RdwOptions, EnvironmentAliases, .env.example, both compose files and the Unraid template.

  • Demo mode. DemoRdwClient plus a seeded lookup, so no plate leaves a demo deployment either.

Frontend

  • APK & recalls card. Shows the days left on the APK and the number of open recalls. Clicking it opens a dialog with the APK date, insurance, each open recall with its risk and repair, the repaired recalls, and when the RDW was last asked.
  • Settings > Licence plate. Styled as a Dutch plate (yellow with the blue EU band), in both themes. Dashes are inserted as you type, following the Dutch side codes (AB-12-34, 12-ABC-3, HPK-98-B). Backspace and Delete skip over dashes, and anything other than letters and digits, or more than 8 characters, is refused.
  • Notification type. A new rdw type in the per-device checklist, hidden while the feature is off.
  • English and Dutch strings for everything new.

Docs

README (feature list, card table, notification table, new "Dutch registration (RDW)" section), CONFIGURATION, ARCHITECTURE, DEMO, HOME_ASSISTANT, the all-in-one README, and GarageStack.Api.http. The .http file also replaces the removed /history endpoint with /stats/daily.

Type of Change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Documentation
  • Dependency update
  • Other

Checklist

  • Tests added or updated (backend: client parsing against recorded RDW responses, refresher, sync service, APK reminder, recalls, plate normalisation, config aliases, auth flow; frontend: card, plate input, store, plate formatting, notification categories)
  • Linting passes (pnpm lint / dotnet build)
  • No secrets or personal data committed
  • Responsive on mobile
  • i18n keys added for any new UI strings
  • If unraid/garagestack.xml changed: synced to joszz/unraid-community-apps after merge, through a pull request there (see "Keeping the Community Apps copy in sync" in documentation/RELEASING.md)

Test plan

  • Backend unit tests: 1417 passed (dotnet test --project src/GarageStack.Tests), build without warnings
  • Frontend unit tests: 1122 passed (vitest run)
  • vue-tsc --build, oxlint, eslint, stylelint and prettier --check clean
  • Mobile layout of the card dialog and the plate field
  • Against the live RDW with RDW_ENABLED=true: enter a plate, check the card fills, wait for a Worker refresh

After merge: the Unraid template gained the three RDW_* variables, so it needs syncing to joszz/unraid-community-apps.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QUS3qyf8owf2McmxrZB6zu
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QUS3qyf8owf2McmxrZB6zu
The field is a yellow plate with the blue EU band and NL, in the plate's
own colours in either theme. The dashes go where the Dutch side codes put
them while typing: at every change between letters and digits, with a
group of four split in two (AB-12-34, 12-ABC-3, HPK-98-B). Backspace or
Delete next to a dash takes the character beyond it, the caret stays
where it was, and anything but letters and digits, or past eight, is
refused. The RDW card writes the plate the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QUS3qyf8owf2McmxrZB6zu
- README: a section on the Dutch registration (the plate field, the
  lookup, the card, the notifications), the card under its on-screen
  name, the RDW among the hosts the container may reach, and the RDW
  type among those the notification checklist leaves out when off.
- ARCHITECTURE: the RDW lookup among the caches, the browser side (the
  rdw store and the side-code formatting of the plate field), and the
  RDW and Valhalla clients behind the shared rate gate.
- DEMO: RDW_ENABLED in the demo's variables, which docker-compose.demo.yml
  now passes through so the note about switching it off holds there too.
- HOME_ASSISTANT and the all-in-one README: the /rdw endpoint and the RDW
  settings among the ones listed.
- GarageStack.Api.http: the RDW endpoints, and /stats/daily in place of
  the /history endpoint that no longer exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QUS3qyf8owf2McmxrZB6zu
@joszz
joszz enabled auto-merge October 19, 2026 10:15
@joszz
joszz merged commit 868d06e into main Oct 19, 2026
21 checks passed
@joszz
joszz deleted the claude/gallant-euler-4d7ks9 branch October 19, 2026 10:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants