# 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 | | `LISTEN_ADDR` | default `:8080` | ## Endpoints The ingest endpoints accept `Authorization: Bearer ` 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 DC session 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. ## 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//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: . podman push git.stinnesbeck.com/nils/mg4-server: ``` 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.