The server's host is car.k8s.stinnesbeck.com, not car.stinnesbeck.com: IngressRoute, PUBLIC_URL (so the pairing QR code points there), the app's pre-filled server URL and hint, and the docs. Grafana's placeholder host follows to car-grafana.k8s.stinnesbeck.com, so one *.k8s.stinnesbeck.com default certificate covers both. App 2.4.1 for the new default; the server image is unchanged (its URL comes from the manifest). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
103 lines
4.3 KiB
Markdown
103 lines
4.3 KiB
Markdown
# 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.
|
|
|
|
- `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:
|
|
|
|
```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.
|