The public Overpass answered 429/502 and needs ~40 s a tile while the car gives up after 8 s, so the server cancelled its queries and the car's retries added load. Now the server fetches only the tiles a car asks for, as OpenStreetMap vector tiles from OpenFreeMap (MAP_TILES_URL), decodes the transportation layer with a small reader of its own, and keeps each tile 30 days on a volume (MAP_CACHE_DIR). A fetch finishes even after the car has stopped waiting; the car hears 503 with Retry-After and gets the tile on its next try. App: OpenFreeMap/OpenMapTiles attribution on the map, retry after 15 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
151 lines
8.3 KiB
Markdown
151 lines
8.3 KiB
Markdown
# Nils' MG4 — car server
|
||
|
||
A small Go service at car.k8s.stinnesbeck.com: the API the car writes to. Data lives in
|
||
InfluxDB 2; Grafana reads it from there for the dashboards (`../k8s/grafana/`). The car sends
|
||
its live data to ABRP itself; the server does not talk to ABRP (removed in 2.4.0).
|
||
|
||
```
|
||
car ──live telemetry───▶ server
|
||
──daily chunks─────▶ │
|
||
──charging sessions▶ ▼
|
||
InfluxDB 2 ◀── Grafana
|
||
```
|
||
|
||
## Configuration
|
||
|
||
Environment only; the server refuses to start when a required value is missing.
|
||
|
||
| Name | Notes |
|
||
|---|---|
|
||
| `INFLUX_URL`, `INFLUX_TOKEN`, `INFLUX_ORG` | required; the token needs read/write on the bucket |
|
||
| `INFLUX_BUCKET` | default `mg4` |
|
||
| `INGEST_TOKEN` | required, at least 24 characters: `openssl rand -hex 32` |
|
||
| `APPROVAL_PASSWORD` | optional, at least 12 characters; switches pairing on (see below) |
|
||
| `PUBLIC_URL` | the address a phone uses, for the pairing QR code; derived from the request if empty |
|
||
| `FORGE_URL`, `FORGE_REPO`, `FORGE_TOKEN` | optional; app updates (below). The token only needs to read the repository |
|
||
| `MAP_TILES_URL` | a TileJSON naming the vector tiles the map's roads come from; default OpenFreeMap's `https://tiles.openfreemap.org/planet`, `off` switches maps off, otherwise https only |
|
||
| `MAP_CACHE_DIR` | where fetched map tiles are kept across restarts (the manifest mounts a volume there); empty keeps them in memory only |
|
||
| `LISTEN_ADDR` | default `:8080` |
|
||
|
||
## Endpoints
|
||
|
||
The ingest endpoints accept `Authorization: Bearer <token>` with either the shared `INGEST_TOKEN`
|
||
or a paired car's own token. A paired token only works for the installation id it was paired
|
||
with (403 otherwise); the shared token works for any.
|
||
|
||
- `POST /v1/telemetry` — `{ installationId, samples: [...] }`, up to 500 samples in ABRP's `tlm`
|
||
field names and units (`utc` in epoch seconds; flags as 0/1 or booleans). Unknown fields are
|
||
dropped, known ones bounded: a value out of range is dropped on its own, and a sample without
|
||
a plausible time (before 2001, or more than an hour ahead) is dropped whole. The rest is stored
|
||
and the answer is `200 {stored, droppedCount, dropped}`, even when nothing was usable, since
|
||
the car would only send it again.
|
||
- `PUT /v1/consumption/daily` — one delta chunk of a day's consumption.
|
||
- `PUT /v1/charging-sessions/{sessionId}` — a finished charging session (AC or DC, as far as the car was awake) with its curve.
|
||
- `GET /v1/consumption/history/{installationId}` — the car reads its own totals back.
|
||
- `GET /v1/health` — `{"status":"ok"}`.
|
||
|
||
A malformed request is a 400 naming the field; a database failure is a 500 without detail.
|
||
|
||
## App updates
|
||
|
||
`GET /v1/app/releases` and `GET /v1/app/assets/{release}/{asset}/{name}`, with the car's token:
|
||
the repository's releases, read from the forge with `FORGE_TOKEN`, in GitHub's field names
|
||
with download URLs pointing back at this server, and their attachments streamed through. The
|
||
repository stays private and the forge token never reaches the car; redirects away from the
|
||
forge are not followed. The car verifies everything itself (`OTA.md`). 404 when unset.
|
||
|
||
Each check and each download is logged with the car's installation ID ("shared token" for the
|
||
shared ingest token): `update check` (how many releases, the newest pre-release), `update
|
||
download started` (file, size), then `update handed out` (bytes sent, time taken) or `update
|
||
download cut short`. The forge's address and token never appear in the log.
|
||
|
||
## Map roads
|
||
|
||
`POST /v1/map/roads` `{z, x, y}`, with the car's token: the drivable roads (motorway to minor
|
||
streets) of one web-mercator tile at zoom 14, for the app's "Grid with map" style. The car names
|
||
the tile, not its position, and in the body, so it stays out of access logs. The answer is
|
||
`{z, x, y, extent: 4096, ways: [{c, p: [x, y, …]}]}`: per road its class (0 motorway and trunk,
|
||
1 primary and secondary, 2 tertiary, 3 minor streets) and its points in tile units, simplified to
|
||
about 1.5 m; gzipped when the car accepts it (10–15 KB a tile in a city before that).
|
||
|
||
The roads are fetched **only for the tiles a car asks for**: the OpenStreetMap vector tile
|
||
(OpenMapTiles schema) of that square from the TileJSON at `MAP_TILES_URL` (by default
|
||
OpenFreeMap: free, no key, no request limits; its template moves with each weekly build and is
|
||
looked up every 6 hours), decoded by a small reader of our own (`mvt.go`, the `transportation`
|
||
layer only). Each tile is fetched once and kept for 30 days in `MAP_CACHE_DIR` (and the last 300
|
||
in memory); at most two fetches run at once. A request waits up to 6 s; a slower fetch goes on
|
||
and is kept, and the car hears 503 with `Retry-After: 10` and gets the tile on its next try.
|
||
400 for anything but zoom 14 or a tile outside it, 503 when the tile host fails (not cached),
|
||
404 when maps are off. The log says `map tile fetched` with the size, never the tile.
|
||
Map data © OpenStreetMap contributors (ODbL), tiles © OpenMapTiles, served by OpenFreeMap.
|
||
|
||
## Settings from a phone
|
||
|
||
The ABRP credentials are long to type on the car. With pairing on, the car (with its own token)
|
||
opens `POST /v1/settings/start` `{installationId}` and shows `verificationUriComplete` as a QR
|
||
code. The phone fills in the form at `/settings?code=…` (ABRP API key and user token, charging
|
||
price, usable capacity; empty fields are left alone) and sends it with the approval password.
|
||
The car polls `POST /v1/settings/poll` `{deviceCode}`: 202 pending, 200 with the values (handed
|
||
out once, `Cache-Control: no-store`), 410 expired or unknown. A paired token only reaches its own
|
||
installation's request. The values live in memory only, at most ten minutes, and are never
|
||
stored, logged or put back into the page.
|
||
|
||
## Pairing a car by QR code
|
||
|
||
So nobody types a token in the car. Modelled on the OAuth device authorization grant (RFC 8628);
|
||
on only when `APPROVAL_PASSWORD` is set.
|
||
|
||
1. The car calls `POST /v1/pair/start` `{installationId}` and shows the returned
|
||
`verificationUriComplete` as a QR code, plus the short `userCode` (e.g. `K7F-3QX`).
|
||
2. The phone opens `/pair?code=…`, the owner checks the code and approves with the approval
|
||
password. Five wrong passwords from one address lock that address out for a minute; other
|
||
addresses are unaffected (the address is the last `X-Forwarded-For` entry, which Traefik sets).
|
||
An address may hold three open requests; a car starting again replaces its own.
|
||
3. The car polls `POST /v1/pair/poll` `{deviceCode}`: 202 pending, 200 with its own token
|
||
(handed out once), 403 denied, 410 expired (after 10 minutes) or unknown.
|
||
|
||
Each car gets its own 43-character token; only its SHA-256 is stored (measurement `device` in
|
||
the same bucket), and the token is only handed out, and only accepted, once the device is
|
||
persisted, so a restart never loses a paired car. Manage cars with the approval password:
|
||
|
||
```sh
|
||
curl -H "Authorization: Bearer $APPROVAL_PASSWORD" https://car.k8s.stinnesbeck.com/v1/devices
|
||
curl -X POST -H "Authorization: Bearer $APPROVAL_PASSWORD" https://car.k8s.stinnesbeck.com/v1/devices/<id>/revoke
|
||
```
|
||
|
||
## Storage
|
||
|
||
One bucket, `installationId` tag on every point:
|
||
|
||
| Measurement | Timestamp | Tags | Fields |
|
||
|---|---|---|---|
|
||
| `telemetry` | sample `utc` | — | the ABRP fields that were sent |
|
||
| `consumption_chunk` | `recordedAt` | `date` | km, kwh, hours, socDrop, sohPercent |
|
||
| `charging_session` | `startedAt` | `sessionId` | endedAtMs, duration, energy, cost, start/end SOC |
|
||
| `charging_curve` | point time | `sessionId` | powerKw, socPercent |
|
||
| `device` | paired at | `deviceId` | tokenHash, installationId, revoked |
|
||
|
||
A point with the same measurement, tags and timestamp replaces the earlier one, so the car's
|
||
retries are idempotent: a cached chunk is re-sent with its original `recordedAt`. Days and the
|
||
lifetime total are summed from the chunks when read. Numbers are always written as floats,
|
||
because InfluxDB fixes a field's type on first write.
|
||
|
||
## Development
|
||
|
||
```sh
|
||
nix develop # from the repository root: Go, govulncheck, InfluxDB 2
|
||
cd server
|
||
go test ./...
|
||
govulncheck ./...
|
||
```
|
||
|
||
## Container image
|
||
|
||
```sh
|
||
podman build -t git.stinnesbeck.com/nils/mg4-server:<version> .
|
||
podman push git.stinnesbeck.com/nils/mg4-server:<version>
|
||
```
|
||
|
||
The build runs `go vet` and the tests. The image is a static binary on distroless (about
|
||
11 MB), running as UID 65532 on port 8080.
|