Files
mg4/server
nilsandClaude Opus 5.5 d5f68e1bd9 Server 2.1.0: pair cars by QR code instead of typing the token
Modelled on the OAuth device authorization grant (RFC 8628) and on
only when APPROVAL_PASSWORD is set (at least 12 characters):

- POST /v1/pair/start opens a request for an installation and returns
  a short user code and the /pair URL for the car's QR code;
  POST /v1/pair/poll returns pending, the car's own token (once),
  denied or expired. Requests live 10 minutes, in memory.
- GET/POST /pair is a script-free approval page for the phone
  (strict CSP, no framing). Five wrong passwords lock approvals for a
  minute.
- Each car gets its own 43-character token; only its SHA-256 is stored
  (measurement "device"), and the device is persisted before the car
  can collect the token. Tokens are loaded at startup, so a restart
  keeps paired cars working; the server refuses to start if they cannot
  be loaded.
- GET /v1/devices and POST /v1/devices/{id}/revoke, authenticated with
  the approval password, list and revoke cars.
- The shared INGEST_TOKEN keeps working.

31 tests (race detector clean), govulncheck clean. Tested end to end
against InfluxDB 2.7.12: pairing, wrong password, single collection,
persistence across restarts, revocation (also across a restart), and
that InfluxDB holds only the hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-17 16:16:19 +02:00
..

Nils' MG4 — car server

A small Go service at car.stinnesbeck.com: the API the car writes to, and the ABRP forwarder. Data lives in InfluxDB 2; Grafana reads it from there for the dashboards (../k8s/grafana/).

car ──live telemetry───▶ server ──▶ ABRP  api.iternio.com/1/tlm/send
    ──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
ABRP_API_KEY, ABRP_USER_TOKEN optional; both needed to forward
ABRP_MIN_INTERVAL_SECONDS default 5
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

The ABRP credentials never reach the car.

Endpoints

The ingest endpoints accept Authorization: Bearer <token> with either the shared INGEST_TOKEN or a paired car's own token.

  • 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. Stored, then the newest sample is forwarded to ABRP if it is under 5 minutes old.
  • 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 bad 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 lock approvals for a minute.
  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 device is persisted before the car can collect the token, so a restart never loses a paired car. Manage cars with the approval password:

curl -H "Authorization: Bearer $APPROVAL_PASSWORD" https://car.stinnesbeck.com/v1/devices
curl -X POST -H "Authorization: Bearer $APPROVAL_PASSWORD" https://car.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

nix develop          # from the repository root: Go, govulncheck, InfluxDB 2
cd server
go test ./...
govulncheck ./...

Container image

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.