Telemetry no longer loses queued batches. The server drops a bad value, or a sample without a plausible time, instead of rejecting the batch, and answers 200 with what it dropped. The app sends only values inside the server's ranges (speed as a magnitude, so reversing is fine) and no samples before the head unit's clock is set. Server: - Pairing hands out and accepts a token only after the device is saved; a failed save leaves the request pending. - A paired token serves only the installation id it was paired with (403 otherwise); the shared INGEST_TOKEN still serves any. - Wrong-password lockouts and open pairing requests are limited per client address (last X-Forwarded-For entry), so a stranger cannot lock the owner out; a car starting again replaces its own request. - ABRP forwarding skips samples stamped ahead of the server's clock, which used to stop it until a restart. App: - Settings written to plaintext during a keystore failure move back into encrypted storage; the stored installation id wins. - The installation id is derived from ANDROID_ID on first start, so a reinstall finds its history; a stored or imported id still wins. - The scanned config file is deleted after import (it holds the token). - GPS stops while live telemetry is off or the server unconfigured, and pauses while parked and idle, keeping the fix the car parked with. - Range estimates use the monotonic clock. - One HTTP helper (ServerHttp) for telemetry, sync and pairing. - Last Turkish strings translated; mise logs shows the NilsMG4 tags. Grafana reads InfluxDB with its own read-only token (new grafana-influx Secret, created by hand as described in k8s/README.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
5.0 KiB
Nils' MG4 — car server
A small Go service at car.k8s.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. 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'stlmfield names and units (utcin 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 is200 {stored, droppedCount, dropped}, even when nothing was usable, since the car would only send it again. The newest sample is forwarded to ABRP if it is under 5 minutes old and not ahead of the server's clock.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.
- The car calls
POST /v1/pair/start{installationId}and shows the returnedverificationUriCompleteas a QR code, plus the shortuserCode(e.g.K7F-3QX). - 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 lastX-Forwarded-Forentry, which Traefik sets). An address may hold three open requests; a car starting again replaces its own. - 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:
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
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.