Server: InfluxDB 2 storage, live telemetry endpoint, ABRP forwarder

- MongoDB replaced by InfluxDB 2 (one bucket, installationId tag).
  Consumption is stored per chunk at its recordedAt and summed on read,
  so a chunk the car re-sends from its cache overwrites itself instead
  of being counted twice ($inc did count it twice). Lifetime is a
  running total derived on read.
- POST /v1/telemetry takes batches of samples in ABRP's tlm fields; every
  field is bounded, unknown fields are dropped, unread signals stay absent.
- After a successful write the newest fresh sample is forwarded to ABRP
  with ABRP_API_KEY / ABRP_USER_TOKEN from the server environment; rate
  limited, never queued. The car no longer needs ABRP credentials.
- Self-hosting: standalone output, Dockerfile (tests run in the build,
  runs as UID 10001), .env.example and README for the new setup.
- Dev shell gains Node 22 and InfluxDB 2.

Verified locally against InfluxDB 2.7.12: all endpoints, retry
idempotency, and the dashboard pages. Unit tests: 17 pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 14:09:29 +02:00
co-authored by Claude Opus 5.5
parent d4fb4ba3af
commit 4ea66cf424
26 changed files with 821 additions and 395 deletions
+52 -32
View File
@@ -1,68 +1,88 @@
# FatihsMG4 web
# Nils' MG4 — car server
Next.js app on Vercel: the ingest API the car writes to, and a phone-shaped dashboard for
reading the history back. Replaces the standalone Express service that used to live in
`server/`.
Next.js app, self-hosted at car.stinnesbeck.com: the ingest API the car writes to, the ABRP
forwarder, and a phone-shaped dashboard for reading the history back. Data lives in
InfluxDB 2. Based on upstream's Vercel/MongoDB app in FatihsMG4.
**The car never connects to MongoDB and never holds a MongoDB URI.** The URI is a Vercel
environment variable and is only ever read by a server-side route.
```
car ──live telemetry───▶ server ──▶ ABRP api.iternio.com/1/tlm/send
──daily chunks─────▶ │
──charging sessions▶ ▼
InfluxDB 2
```
## Two credentials, on purpose
## Credentials
| | Who holds it | What it opens |
|---|---|---|
| `INGEST_TOKEN` | the car, in the APK | the `PUT` endpoints — write only |
| `DASHBOARD_PASSWORD` | you, in a browser | the dashboard and the `GET` history |
| `INGEST_TOKEN` | the car, entered in the app | the write endpoints, and the car's own history read |
| `DASHBOARD_PASSWORD` | you, in a browser | the dashboard and the `GET /v1/history` reads |
| `ABRP_API_KEY`, `ABRP_USER_TOKEN` | only the server | forwarding live telemetry to ABRP |
The APK is published, so its token must be assumed extractable: anyone can `unzip` an APK
and read its strings. That is survivable here because the write token cannot read anything
back. The password is never compiled into anything.
The ABRP credentials never reach the car. A missing or empty one switches forwarding off.
## Environment variables
Set all five in Vercel → Settings → Environment Variables, for Production and Preview.
See `.env.example`. In Kubernetes they come from the Secret in `../k8s/`.
| Name | Notes |
|---|---|
| `MONGODB_URI` | Atlas connection string |
| `MONGODB_DATABASE` | defaults to `fatihsmg4` |
| `INGEST_TOKEN` | `openssl rand -hex 32` |
| `INFLUX_URL`, `INFLUX_TOKEN`, `INFLUX_ORG`, `INFLUX_BUCKET` | bucket defaults to `mg4`; the token needs read/write on it |
| `INGEST_TOKEN` | at least 24 characters, or every write is refused: `openssl rand -hex 32` |
| `DASHBOARD_PASSWORD` | at least 8 characters, or login is refused outright |
| `SESSION_SECRET` | `openssl rand -hex 32`; rotating it signs every browser out |
Atlas network access must allow Vercel's egress, which is not a fixed address on the Hobby
plan — either allow `0.0.0.0/0` and rely on the database user's credentials, or use Atlas
private networking on a paid plan.
| `ABRP_API_KEY`, `ABRP_USER_TOKEN` | optional; both needed to forward |
| `ABRP_MIN_INTERVAL_SECONDS` | default 5 |
## Endpoints
Write, `Authorization: Bearer <INGEST_TOKEN>`:
- `PUT /v1/consumption/daily`
- `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). 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`
Read, session cookie or `Authorization: Bearer <DASHBOARD_PASSWORD>`:
Read:
- `GET /v1/history/:installationId/consumption`
- `GET /v1/history/:installationId/charging`
- `GET /v1/consumption/history/:installationId` — ingest token; the car reads its own totals back.
- `GET /v1/history/:installationId/consumption`, `.../charging` — session cookie or
`Authorization: Bearer <DASHBOARD_PASSWORD>`.
`GET /v1/health` is unauthenticated and returns `{"status":"ok"}`.
The car builds its URLs as `CONSUMPTION_API_URL + /v1/...`, so the Android build variable
is the bare origin — `https://your-app.vercel.app`, no trailing path.
## Storage
## Indexes
One bucket, `installationId` tag on every point:
There is no boot step on Vercel, so `ensureIndexes()` runs on the first write of each warm
lambda and is skipped afterwards. The unique indexes on `(installationId, date)` and
`(installationId, sessionId)` are what make the upserts idempotent when the car retries a
record it could not confirm.
| 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 |
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. All numbers are written as floats, because
InfluxDB fixes a field's type on first write.
## Local development
```sh
nix develop # from the repository root: Node 22 and InfluxDB 2
cp .env.example .env.local # Next loads .env.local automatically
npm install
npm run dev
npm test # pure validation and session-signing tests, no database
npm test # validation, aggregation and forwarding rules; no database
```
## 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 the unit tests. The image runs as UID 10001 and listens on port 3000.