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:
+52
-32
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user