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
+1 -1
View File
@@ -112,7 +112,7 @@
in in
{ {
default = pkgs.mkShell { default = pkgs.mkShell {
packages = [ jdk android.androidsdk buildCar ]; packages = [ jdk android.androidsdk buildCar pkgs.nodejs_22 pkgs.influxdb2-server pkgs.influxdb2-cli ];
ANDROID_HOME = sdk; ANDROID_HOME = sdk;
ANDROID_SDK_ROOT = sdk; ANDROID_SDK_ROOT = sdk;
+7
View File
@@ -0,0 +1,7 @@
node_modules
.next
.env*
!.env.example
*.tsbuildinfo
Dockerfile
.dockerignore
+15 -2
View File
@@ -1,5 +1,18 @@
MONGODB_URI=mongodb+srv://username:password@cluster.example.mongodb.net/ # InfluxDB 2 — a token with read/write on the bucket.
MONGODB_DATABASE=fatihsmg4 INFLUX_URL=http://influxdb:8086
INFLUX_TOKEN=replace-with-an-influxdb-token
INFLUX_ORG=mg4
INFLUX_BUCKET=mg4
# The car's write token (at least 24 characters): openssl rand -hex 32
INGEST_TOKEN=replace-with-a-long-random-write-token INGEST_TOKEN=replace-with-a-long-random-write-token
# Signs dashboard sessions; rotating it signs every browser out: openssl rand -hex 32
SESSION_SECRET=replace-with-a-long-random-session-secret SESSION_SECRET=replace-with-a-long-random-session-secret
# Dashboard login, at least 8 characters.
DASHBOARD_PASSWORD=replace-with-a-strong-dashboard-password DASHBOARD_PASSWORD=replace-with-a-strong-dashboard-password
# ABRP forwarding; off when either is empty.
ABRP_API_KEY=
ABRP_USER_TOKEN=
# Minimum seconds between two samples forwarded to ABRP.
ABRP_MIN_INTERVAL_SECONDS=5
+27
View File
@@ -0,0 +1,27 @@
# Self-hosted image of the car server: Next.js standalone output on Node 22.
# Configuration comes entirely from the environment at runtime (see .env.example);
# nothing secret is baked in.
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
FROM node:22-alpine AS build
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm test && npm run build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0
COPY --from=build --chown=10001:10001 /app/.next/standalone ./
COPY --from=build --chown=10001:10001 /app/.next/static ./.next/static
USER 10001:10001
EXPOSE 3000
CMD ["node", "server.js"]
+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 Next.js app, self-hosted at car.stinnesbeck.com: the ingest API the car writes to, the ABRP
reading the history back. Replaces the standalone Express service that used to live in forwarder, and a phone-shaped dashboard for reading the history back. Data lives in
`server/`. 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 | | | Who holds it | What it opens |
|---|---|---| |---|---|---|
| `INGEST_TOKEN` | the car, in the APK | the `PUT` endpoints — write only | | `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` history | | `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 The ABRP credentials never reach the car. A missing or empty one switches forwarding off.
and read its strings. That is survivable here because the write token cannot read anything
back. The password is never compiled into anything.
## Environment variables ## 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 | | Name | Notes |
|---|---| |---|---|
| `MONGODB_URI` | Atlas connection string | | `INFLUX_URL`, `INFLUX_TOKEN`, `INFLUX_ORG`, `INFLUX_BUCKET` | bucket defaults to `mg4`; the token needs read/write on it |
| `MONGODB_DATABASE` | defaults to `fatihsmg4` | | `INGEST_TOKEN` | at least 24 characters, or every write is refused: `openssl rand -hex 32` |
| `INGEST_TOKEN` | `openssl rand -hex 32` |
| `DASHBOARD_PASSWORD` | at least 8 characters, or login is refused outright | | `DASHBOARD_PASSWORD` | at least 8 characters, or login is refused outright |
| `SESSION_SECRET` | `openssl rand -hex 32`; rotating it signs every browser out | | `SESSION_SECRET` | `openssl rand -hex 32`; rotating it signs every browser out |
| `ABRP_API_KEY`, `ABRP_USER_TOKEN` | optional; both needed to forward |
Atlas network access must allow Vercel's egress, which is not a fixed address on the Hobby | `ABRP_MIN_INTERVAL_SECONDS` | default 5 |
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.
## Endpoints ## Endpoints
Write, `Authorization: Bearer <INGEST_TOKEN>`: 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` - `PUT /v1/charging-sessions/:sessionId`
Read, session cookie or `Authorization: Bearer <DASHBOARD_PASSWORD>`: Read:
- `GET /v1/history/:installationId/consumption` - `GET /v1/consumption/history/:installationId` — ingest token; the car reads its own totals back.
- `GET /v1/history/:installationId/charging` - `GET /v1/history/:installationId/consumption`, `.../charging` — session cookie or
`Authorization: Bearer <DASHBOARD_PASSWORD>`.
`GET /v1/health` is unauthenticated and returns `{"status":"ok"}`. `GET /v1/health` is unauthenticated and returns `{"status":"ok"}`.
The car builds its URLs as `CONSUMPTION_API_URL + /v1/...`, so the Android build variable ## Storage
is the bare origin — `https://your-app.vercel.app`, no trailing path.
## 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 | Measurement | Timestamp | Tags | Fields |
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 | `telemetry` | sample `utc` | — | the ABRP fields that were sent |
record it could not confirm. | `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 ## Local development
```sh ```sh
nix develop # from the repository root: Node 22 and InfluxDB 2
cp .env.example .env.local # Next loads .env.local automatically cp .env.example .env.local # Next loads .env.local automatically
npm install npm install
npm run dev 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.
+2
View File
@@ -1,5 +1,7 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
// A self-contained server in .next/standalone, for the container image.
output: "standalone",
// Every page reads live telemetry; a cached shell would show yesterday's drive. // Every page reads live telemetry; a cached shell would show yesterday's drive.
experimental: { serverActions: { bodySizeLimit: "1mb" } }, experimental: { serverActions: { bodySizeLimit: "1mb" } },
}; };
+6 -149
View File
@@ -8,7 +8,7 @@
"name": "fatihsmg4-web", "name": "fatihsmg4-web",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"mongodb": "^6.20.0", "@influxdata/influxdb-client": "^1.35.0",
"next": "^15.5.4", "next": "^15.5.4",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0" "react-dom": "^19.1.0"
@@ -533,14 +533,11 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@mongodb-js/saslprep": { "node_modules/@influxdata/influxdb-client": {
"version": "1.6.0", "version": "1.35.0",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.6.0.tgz", "resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.35.0.tgz",
"integrity": "sha512-19jGXfpctLDJUELLUZGJ5BllcLT7rHdF+gTTaN2jzqu0bGkUhpdcYtgK2Vux+g3zuBAa0TM/W666QMIjYhYujw==", "integrity": "sha512-woWMi8PDpPQpvTsRaUw4Ig+nOGS/CWwAwS66Fa1Vr/EkW+NEwxI8YfPBsdBMn33jK2Y86/qMiiuX/ROHIkJLTw==",
"license": "MIT", "license": "MIT"
"dependencies": {
"sparse-bitfield": "^3.0.3"
}
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "15.5.27", "version": "15.5.27",
@@ -705,30 +702,6 @@
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
}, },
"node_modules/@types/webidl-conversions": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==",
"license": "MIT"
},
"node_modules/@types/whatwg-url": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz",
"integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==",
"license": "MIT",
"dependencies": {
"@types/webidl-conversions": "*"
}
},
"node_modules/bson": {
"version": "6.10.4",
"resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz",
"integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==",
"license": "Apache-2.0",
"engines": {
"node": ">=16.20.1"
}
},
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001815", "version": "1.0.30001815",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz",
@@ -772,68 +745,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/memory-pager": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
"license": "MIT"
},
"node_modules/mongodb": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.21.0.tgz",
"integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==",
"license": "Apache-2.0",
"dependencies": {
"@mongodb-js/saslprep": "^1.3.0",
"bson": "^6.10.4",
"mongodb-connection-string-url": "^3.0.2"
},
"engines": {
"node": ">=16.20.1"
},
"peerDependencies": {
"@aws-sdk/credential-providers": "^3.188.0",
"@mongodb-js/zstd": "^1.1.0 || ^2.0.0",
"gcp-metadata": "^5.2.0",
"kerberos": "^2.0.1",
"mongodb-client-encryption": ">=6.0.0 <7",
"snappy": "^7.3.2",
"socks": "^2.7.1"
},
"peerDependenciesMeta": {
"@aws-sdk/credential-providers": {
"optional": true
},
"@mongodb-js/zstd": {
"optional": true
},
"gcp-metadata": {
"optional": true
},
"kerberos": {
"optional": true
},
"mongodb-client-encryption": {
"optional": true
},
"snappy": {
"optional": true
},
"socks": {
"optional": true
}
}
},
"node_modules/mongodb-connection-string-url": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz",
"integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==",
"license": "Apache-2.0",
"dependencies": {
"@types/whatwg-url": "^11.0.2",
"whatwg-url": "^14.1.0 || ^13.0.0"
}
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.20", "version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
@@ -938,21 +849,11 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react": { "node_modules/react": {
"version": "19.3.0", "version": "19.3.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
"integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -962,7 +863,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.28.0" "scheduler": "^0.28.0"
}, },
@@ -1048,15 +948,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/sparse-bitfield": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
"license": "MIT",
"dependencies": {
"memory-pager": "^1.0.2"
}
},
"node_modules/styled-jsx": { "node_modules/styled-jsx": {
"version": "5.1.6", "version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -1080,18 +971,6 @@
} }
} }
}, },
"node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -1118,28 +997,6 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
} }
} }
} }
+1 -1
View File
@@ -13,7 +13,7 @@
"node": ">=22" "node": ">=22"
}, },
"dependencies": { "dependencies": {
"mongodb": "^6.20.0", "@influxdata/influxdb-client": "^1.35.0",
"next": "^15.5.4", "next": "^15.5.4",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0" "react-dom": "^19.1.0"
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { handle, parseJson, requireIngest } from "@/lib/api"; import { handle, parseJson, requireIngest } from "@/lib/api";
import { chargingCollection, ensureIndexes } from "@/lib/mongo"; import { writeChargingSession } from "@/lib/store";
import { validateChargingSession } from "@/lib/validate"; import { validateChargingSession } from "@/lib/validate";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -11,15 +11,7 @@ export async function PUT(request: Request, context: { params: Promise<{ session
if (denied) return denied; if (denied) return denied;
return handle(async () => { return handle(async () => {
const { sessionId } = await context.params; const { sessionId } = await context.params;
const record = validateChargingSession(await parseJson(request), sessionId); await writeChargingSession(validateChargingSession(await parseJson(request), sessionId));
await ensureIndexes();
const collection = await chargingCollection();
const updatedAt = new Date();
await collection.updateOne(
{ installationId: record.installationId, sessionId: record.sessionId },
{ $set: { ...record, updatedAt }, $setOnInsert: { createdAt: updatedAt } },
{ upsert: true },
);
return new NextResponse(null, { status: 204 }); return new NextResponse(null, { status: 204 });
}); });
} }
+5 -32
View File
@@ -1,48 +1,21 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { handle, parseJson, requireIngest } from "@/lib/api"; import { handle, parseJson, requireIngest } from "@/lib/api";
import { consumptionCollection, ensureIndexes } from "@/lib/mongo"; import { writeConsumptionChunk } from "@/lib/store";
import { validateConsumption } from "@/lib/validate"; import { validateConsumption } from "@/lib/validate";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
/** /**
* Upsert on (installationId, date), incrementally. The car sends a 2-minute delta chunk * One delta chunk (not a cumulative total) of a day's consumption. Stored as its own point
* (not a cumulative total), so each PUT adds to the day's running totals rather than * at its recordedAt; reads add a day's chunks up. A chunk the car re-sends from its offline
* overwriting them. The app does NOT send lifetime — the backend derives it as the sum of * cache carries the same recordedAt and replaces itself, so a retry is never counted twice.
* all days' totals when history is read, so an APK wipe that zeroes the local counter can
* never corrupt the cloud's lifetime.
*
* $setOnInsert must not touch `day` at all: $inc on `day.km` and $setOnInsert on `day`
* conflict in MongoDB. On an insert, $inc treats a missing field as 0 and adds to it.
*/ */
export async function PUT(request: Request) { export async function PUT(request: Request) {
const denied = requireIngest(request); const denied = requireIngest(request);
if (denied) return denied; if (denied) return denied;
return handle(async () => { return handle(async () => {
const record = validateConsumption(await parseJson(request)); await writeConsumptionChunk(validateConsumption(await parseJson(request)));
await ensureIndexes();
const collection = await consumptionCollection();
const updatedAt = new Date();
const $set: Record<string, unknown> = { updatedAt };
if (record.sohPercent !== undefined) $set.sohPercent = record.sohPercent;
await collection.updateOne(
{ installationId: record.installationId, date: record.date },
{
$inc: {
"day.km": record.day.km,
"day.kwh": record.day.kwh,
"day.hours": record.day.hours,
"day.socDrop": record.day.socDrop,
},
$set,
$setOnInsert: {
createdAt: updatedAt,
},
},
{ upsert: true },
);
return new NextResponse(null, { status: 204 }); return new NextResponse(null, { status: 204 });
}); });
} }
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { handle, requireIngest } from "@/lib/api"; import { handle, requireIngest } from "@/lib/api";
import { consumptionCollection } from "@/lib/mongo"; import { readDays } from "@/lib/store";
import { validateUuid } from "@/lib/validate"; import { validateUuid } from "@/lib/validate";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -21,23 +21,10 @@ export async function GET(request: Request, context: { params: Promise<{ install
if (denied) return denied; if (denied) return denied;
return handle(async () => { return handle(async () => {
const { installationId } = await context.params; const { installationId } = await context.params;
const collection = await consumptionCollection(); const days = await readDays(validateUuid(installationId, "installationId"));
const records = await collection // Lifetime = sum of every day's totals, which is the newest day's running total.
.find({ installationId: validateUuid(installationId, "installationId") }, { projection: { _id: 0 } }) const lifetime = days[0]?.lifetime ?? { km: 0, kwh: 0, hours: 0, socDrop: 0 };
.sort({ date: -1 }) const records = days.slice(0, 400).map(({ lifetime: _lifetime, ...day }) => day);
.limit(400)
.toArray();
// Lifetime = sum of every day's totals. Derived, never stored, so a corrupted or
// stale lifetime field in any single document cannot poison the total.
const lifetime = records.reduce(
(sum, r) => ({
km: sum.km + (r.day?.km ?? 0),
kwh: sum.kwh + (r.day?.kwh ?? 0),
hours: sum.hours + (r.day?.hours ?? 0),
socDrop: sum.socDrop + (r.day?.socDrop ?? 0),
}),
{ km: 0, kwh: 0, hours: 0, socDrop: 0 },
);
return NextResponse.json({ records, lifetime }); return NextResponse.json({ records, lifetime });
}); });
} }
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { handle, requireReader } from "@/lib/api"; import { handle, requireReader } from "@/lib/api";
import { chargingCollection } from "@/lib/mongo"; import { readChargingSessions } from "@/lib/store";
import { validateUuid } from "@/lib/validate"; import { validateUuid } from "@/lib/validate";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -11,12 +11,7 @@ export async function GET(request: Request, context: { params: Promise<{ install
if (denied) return denied; if (denied) return denied;
return handle(async () => { return handle(async () => {
const { installationId } = await context.params; const { installationId } = await context.params;
const collection = await chargingCollection(); const records = await readChargingSessions(validateUuid(installationId, "installationId"), 200);
const records = await collection
.find({ installationId: validateUuid(installationId, "installationId") }, { projection: { _id: 0 } })
.sort({ startedAt: -1 })
.limit(200)
.toArray();
return NextResponse.json({ records }); return NextResponse.json({ records });
}); });
} }
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { handle, requireReader } from "@/lib/api"; import { handle, requireReader } from "@/lib/api";
import { consumptionCollection } from "@/lib/mongo"; import { readDays } from "@/lib/store";
import { validateUuid } from "@/lib/validate"; import { validateUuid } from "@/lib/validate";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -11,12 +11,7 @@ export async function GET(request: Request, context: { params: Promise<{ install
if (denied) return denied; if (denied) return denied;
return handle(async () => { return handle(async () => {
const { installationId } = await context.params; const { installationId } = await context.params;
const collection = await consumptionCollection(); const records = (await readDays(validateUuid(installationId, "installationId"))).slice(0, 400);
const records = await collection
.find({ installationId: validateUuid(installationId, "installationId") }, { projection: { _id: 0 } })
.sort({ date: -1 })
.limit(400)
.toArray();
return NextResponse.json({ records }); return NextResponse.json({ records });
}); });
} }
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { forwardToAbrp } from "@/lib/abrp";
import { handle, parseJson, requireIngest } from "@/lib/api";
import { writeTelemetry } from "@/lib/store";
import { validateTelemetryBatch } from "@/lib/validate";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* A batch of live samples from the car. Stored first; only once the write succeeded is the
* newest sample handed to ABRP, so the car's retry of a failed request cannot leave ABRP
* with data the database never got.
*/
export async function POST(request: Request) {
const denied = requireIngest(request);
if (denied) return denied;
return handle(async () => {
const batch = validateTelemetryBatch(await parseJson(request));
await writeTelemetry(batch.installationId, batch.samples);
forwardToAbrp(batch.samples);
return new NextResponse(null, { status: 204 });
});
}
+88
View File
@@ -0,0 +1,88 @@
import { TELEMETRY_FLAGS, type TelemetrySample } from "./types.ts";
/**
* Forwards live telemetry to A Better Route Planner.
*
* The ABRP credentials exist only here, as ABRP_API_KEY and ABRP_USER_TOKEN in the server's
* environment; the car never holds them. Forwarding is off when either is unset.
*
* Only the newest sample of a batch is sent, and at most one every ABRP_MIN_INTERVAL_SECONDS
* (default 5). A failed forward is logged and dropped, never queued: ABRP plans from the
* car's state now, and a backlog replayed later would describe a car that has moved on.
* The stored copy in InfluxDB is unaffected either way.
*/
const API_URL = "https://api.iternio.com/1/tlm/send";
const TIMEOUT_MS = 8_000;
/** Older than this, a sample is history rather than live state, and ABRP gets nothing. */
const MAX_AGE_SECONDS = 300;
const state = globalThis as unknown as { abrpLastUtc?: number; abrpLastSentMs?: number };
export function abrpConfigured(): boolean {
return Boolean(process.env.ABRP_API_KEY && process.env.ABRP_USER_TOKEN);
}
function minIntervalMs(): number {
const seconds = Number(process.env.ABRP_MIN_INTERVAL_SECONDS ?? "5");
return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : 5000;
}
/** The tlm object ABRP expects: the same field names, flags as 0/1. */
export function toTlm(sample: TelemetrySample): Record<string, number> {
const tlm: Record<string, number> = {};
for (const [field, value] of Object.entries(sample)) {
if (typeof value === "number") tlm[field] = value;
}
for (const field of TELEMETRY_FLAGS) {
const value = sample[field];
if (value !== undefined) tlm[field] = value ? 1 : 0;
}
return tlm;
}
/** Picks the sample to forward, or null when there is nothing new or it is too soon. */
export function pickForForward(samples: TelemetrySample[], nowMs: number): TelemetrySample | null {
if (samples.length === 0) return null;
const newest = samples.reduce((a, b) => (b.utc > a.utc ? b : a));
if (nowMs / 1000 - newest.utc > MAX_AGE_SECONDS) return null;
if (state.abrpLastUtc !== undefined && newest.utc <= state.abrpLastUtc) return null;
if (state.abrpLastSentMs !== undefined && nowMs - state.abrpLastSentMs < minIntervalMs()) return null;
return newest;
}
/** Fire-and-forget: the car's request is answered without waiting for ABRP. */
export function forwardToAbrp(samples: TelemetrySample[]): void {
if (!abrpConfigured()) return;
const nowMs = Date.now();
const sample = pickForForward(samples, nowMs);
if (!sample) return;
state.abrpLastUtc = sample.utc;
state.abrpLastSentMs = nowMs;
void send(sample).catch((error) => console.warn("ABRP forward failed:", error instanceof Error ? error.message : error));
}
async function send(sample: TelemetrySample): Promise<void> {
// Credentials go in the form body, never the URL, which ends up in access logs.
const body = new URLSearchParams({
api_key: process.env.ABRP_API_KEY ?? "",
token: process.env.ABRP_USER_TOKEN ?? "",
tlm: JSON.stringify(toTlm(sample)),
});
const response = await fetch(API_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!response.ok) {
// The response body can echo the request; only the status is logged.
console.warn(`ABRP forward rejected: HTTP ${response.status}`);
}
}
/** For tests: forget what was forwarded. */
export function resetAbrpState(): void {
state.abrpLastUtc = undefined;
state.abrpLastSentMs = undefined;
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { hasIngestToken, hasSession, checkDashboardPassword } from "./auth"; import { hasIngestToken, hasSession, checkDashboardPassword } from "./auth";
import { ValidationError } from "./validate"; import { ValidationError } from "./validate";
/** Every endpoint touches MongoDB, which rules out the edge runtime. */ /** Every endpoint touches InfluxDB, which rules out the edge runtime. */
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
+21 -24
View File
@@ -1,11 +1,12 @@
import { chargingCollection, consumptionCollection, type ChargingSession, type DailyConsumption } from "./mongo"; import { installationIds, readChargingSessions, readDays, readLatestTelemetry } from "./store";
import type { ChargingSession, DailyConsumption, TelemetrySample } from "./types";
/** /**
* Reads for the dashboard. * Reads for the dashboard.
* *
* Every query is scoped to one installation id. There is one car today, but the id is in * Every query is scoped to one installation id. There is one car today, but the id is on
* the schema and in the unique indexes, so scoping now costs nothing and means a second * every point, so scoping now costs nothing and means a second head unit does not
* head unit does not silently merge its history into the first one's charts. * silently merge its history into the first one's charts.
*/ */
export interface Overview { export interface Overview {
@@ -13,36 +14,32 @@ export interface Overview {
installationCount: number; installationCount: number;
days: DailyConsumption[]; days: DailyConsumption[];
sessions: ChargingSession[]; sessions: ChargingSession[];
latest: (TelemetrySample & { at: Date }) | null;
} }
/** The installation that reported most recently — the car in use, if there are ever two. */ /** The installation that reported most recently — the car in use, if there are ever two. */
export async function latestInstallationId(): Promise<{ id: string | null; count: number }> { export async function latestInstallationId(): Promise<{ id: string | null; count: number }> {
const collection = await consumptionCollection(); const ids = await installationIds();
const ids = await collection.distinct("installationId"); return { id: ids[0] ?? null, count: ids.length };
if (ids.length === 0) {
const charging = await chargingCollection();
const chargingIds = await charging.distinct("installationId");
return { id: chargingIds[0] ?? null, count: chargingIds.length };
}
if (ids.length === 1) return { id: ids[0], count: 1 };
const newest = await collection.find({}, { projection: { installationId: 1 } })
.sort({ date: -1 }).limit(1).next();
return { id: newest?.installationId ?? ids[0], count: ids.length };
} }
export async function loadOverview(dayLimit = 60, sessionLimit = 60): Promise<Overview> { export async function loadOverview(dayLimit = 60, sessionLimit = 60): Promise<Overview> {
const { id, count } = await latestInstallationId(); const { id, count } = await latestInstallationId();
if (!id) return { installationId: null, installationCount: 0, days: [], sessions: [] }; if (!id) return { installationId: null, installationCount: 0, days: [], sessions: [], latest: null };
const [consumption, charging] = await Promise.all([consumptionCollection(), chargingCollection()]); const [days, sessions, latest] = await Promise.all([
const [days, sessions] = await Promise.all([ readDays(id),
consumption.find({ installationId: id }, { projection: { _id: 0 } }) readChargingSessions(id, sessionLimit),
.sort({ date: -1 }).limit(dayLimit).toArray(), readLatestTelemetry(id),
charging.find({ installationId: id }, { projection: { _id: 0 } })
.sort({ startedAt: -1 }).limit(sessionLimit).toArray(),
]); ]);
// Charts read left-to-right in time; the queries sort newest-first so the limit keeps // Charts read left-to-right in time; the reads come newest-first so the limit keeps
// the recent end rather than the beginning of history. // the recent end rather than the beginning of history.
return { installationId: id, installationCount: count, days: days.reverse(), sessions }; return {
installationId: id,
installationCount: count,
days: days.slice(0, dayLimit).reverse(),
sessions,
latest,
};
} }
export interface ChargeTotals { export interface ChargeTotals {
+43
View File
@@ -0,0 +1,43 @@
import type { ConsumptionChunk, DailyConsumption, Totals } from "./types.ts";
/**
* Folds chunks into days, newest day first, each with its running lifetime total.
* Pure, so the arithmetic is tested without a database.
*/
export function aggregateDays(chunks: ConsumptionChunk[]): DailyConsumption[] {
const byDate = new Map<string, DailyConsumption & { sohAt: number }>();
for (const chunk of chunks) {
let day = byDate.get(chunk.date);
if (!day) {
day = {
installationId: chunk.installationId,
date: chunk.date,
recordedAt: chunk.recordedAt,
day: { km: 0, kwh: 0, hours: 0, socDrop: 0 },
sohAt: -Infinity,
};
byDate.set(chunk.date, day);
}
day.day.km += chunk.day.km;
day.day.kwh += chunk.day.kwh;
day.day.hours += chunk.day.hours;
day.day.socDrop += chunk.day.socDrop;
const at = chunk.recordedAt.getTime();
if (at > day.recordedAt.getTime()) day.recordedAt = chunk.recordedAt;
// The newest manual SoH of the day wins.
if (chunk.sohPercent !== undefined && at > day.sohAt) {
day.sohPercent = chunk.sohPercent;
day.sohAt = at;
}
}
const ascending = [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date));
const running: Totals = { km: 0, kwh: 0, hours: 0, socDrop: 0 };
const days = ascending.map(({ sohAt: _sohAt, ...day }) => {
running.km += day.day.km;
running.kwh += day.day.kwh;
running.hours += day.day.hours;
running.socDrop += day.day.socDrop;
return { ...day, lifetime: { ...running } };
});
return days.reverse();
}
+40
View File
@@ -0,0 +1,40 @@
import { InfluxDB, type QueryApi, type WriteApi } from "@influxdata/influxdb-client";
/**
* One InfluxDB client per server process.
*
* Configured entirely from the environment so the image carries no credentials:
* INFLUX_URL, INFLUX_TOKEN, INFLUX_ORG and INFLUX_BUCKET (default "mg4"). The global
* survives module reloads in dev, where Next re-evaluates modules on every edit.
*/
const globalForInflux = globalThis as unknown as { influx?: InfluxDB };
function env(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is not configured`);
return value;
}
function client(): InfluxDB {
if (!globalForInflux.influx) {
globalForInflux.influx = new InfluxDB({ url: env("INFLUX_URL"), token: env("INFLUX_TOKEN"), timeout: 10_000 });
}
return globalForInflux.influx;
}
export function bucket(): string {
return process.env.INFLUX_BUCKET || "mg4";
}
/** A write API with millisecond precision; the caller must close() it to flush. */
export function writeApi(): WriteApi {
return client().getWriteApi(env("INFLUX_ORG"), bucket(), "ms", {
// The caller waits for close(); a failed write must reach it rather than be retried
// in the background after the car has already been told it succeeded.
maxRetries: 0,
});
}
export function queryApi(): QueryApi {
return client().getQueryApi(env("INFLUX_ORG"));
}
-100
View File
@@ -1,100 +0,0 @@
import { MongoClient, type Collection, type Db } from "mongodb";
/**
* One MongoClient per warm lambda, cached on globalThis.
*
* A serverless function is re-entered far more often than it is cold-started, and a fresh
* MongoClient per request opens a fresh pool per request — Atlas starts refusing
* connections long before the traffic justifies it. The global survives module reloads in
* dev, where Next re-evaluates modules on every edit.
*/
const globalForMongo = globalThis as unknown as { mongoClient?: Promise<MongoClient> };
export interface Totals {
km: number;
kwh: number;
hours: number;
socDrop: number;
}
export interface DailyConsumption {
installationId: string;
date: string;
recordedAt: Date;
day: Totals;
/** Optional — the backend derives lifetime as the sum of all days' totals. */
lifetime?: Totals;
sohPercent?: number;
createdAt: Date;
updatedAt: Date;
}
export interface CurvePoint {
timestamp: Date;
powerKw: number;
socPercent?: number;
}
export interface ChargingSession {
installationId: string;
sessionId: string;
startedAt: Date;
endedAt: Date;
durationSeconds: number;
energyKwh: number;
chargingLossPercent: number;
gridEnergyKwh: number;
pricePerKwh: number;
totalCost: number;
isDemo?: boolean;
startSocPercent?: number;
endSocPercent?: number;
curve: CurvePoint[];
createdAt: Date;
updatedAt: Date;
}
function clientPromise(): Promise<MongoClient> {
const uri = process.env.MONGODB_URI;
if (!uri) throw new Error("MONGODB_URI is not configured");
if (!globalForMongo.mongoClient) {
globalForMongo.mongoClient = new MongoClient(uri, {
serverSelectionTimeoutMS: 10_000,
maxPoolSize: 10,
}).connect();
}
return globalForMongo.mongoClient;
}
async function db(): Promise<Db> {
const client = await clientPromise();
return client.db(process.env.MONGODB_DATABASE || "fatihsmg4");
}
export async function consumptionCollection(): Promise<Collection<DailyConsumption>> {
return (await db()).collection<DailyConsumption>("daily_consumption");
}
export async function chargingCollection(): Promise<Collection<ChargingSession>> {
return (await db()).collection<ChargingSession>("charging_sessions");
}
/**
* Creates the indexes the Express service used to create at boot.
*
* There is no boot here — a lambda starts on a request — so this runs on the first write
* of each warm instance and is skipped afterwards. `createIndex` is idempotent, but it is
* still a round trip, and the uniqueness constraints are what make the upserts safe.
*/
let indexesEnsured = false;
export async function ensureIndexes(): Promise<void> {
if (indexesEnsured) return;
const [consumption, charging] = await Promise.all([consumptionCollection(), chargingCollection()]);
await Promise.all([
consumption.createIndex({ installationId: 1, date: 1 }, { unique: true }),
consumption.createIndex({ installationId: 1, date: -1 }),
charging.createIndex({ installationId: 1, sessionId: 1 }, { unique: true }),
charging.createIndex({ installationId: 1, startedAt: -1 }),
]);
indexesEnsured = true;
}
+225
View File
@@ -0,0 +1,225 @@
import { Point, flux } from "@influxdata/influxdb-client";
import { aggregateDays } from "./days.ts";
import { bucket, queryApi, writeApi } from "./influx.ts";
import {
TELEMETRY_FLAGS,
TELEMETRY_NUMBERS,
type ChargingSession,
type ConsumptionChunk,
type CurvePoint,
type DailyConsumption,
type TelemetrySample,
} from "./types.ts";
/**
* Storage on InfluxDB 2. One bucket; every point carries an `installationId` tag.
*
* consumption_chunk time = recordedAt tags: date km, kwh, hours, socDrop, sohPercent
* charging_session time = startedAt tags: sessionId summary fields
* charging_curve time = point time tags: sessionId powerKw, socPercent
* telemetry time = sample utc ABRP tlm fields
*
* Idempotency comes from InfluxDB itself: a point with the same measurement, tags and
* timestamp replaces the earlier one. The car re-sends a cached chunk with its original
* recordedAt and a session under its own id, so a retry overwrites instead of adding.
*
* Every numeric field is written as a float. InfluxDB fixes a field's type on first
* write, so a 0 sent as an integer would make every later 0.5 a rejected write.
*/
const TAG = "installationId";
async function write(points: Point[]): Promise<void> {
const api = writeApi();
api.writePoints(points);
await api.close();
}
export async function writeConsumptionChunk(chunk: ConsumptionChunk): Promise<void> {
const point = new Point("consumption_chunk")
.tag(TAG, chunk.installationId)
.tag("date", chunk.date)
.floatField("km", chunk.day.km)
.floatField("kwh", chunk.day.kwh)
.floatField("hours", chunk.day.hours)
.floatField("socDrop", chunk.day.socDrop)
.timestamp(chunk.recordedAt);
if (chunk.sohPercent !== undefined) point.floatField("sohPercent", chunk.sohPercent);
await write([point]);
}
export async function writeChargingSession(session: ChargingSession): Promise<void> {
const summary = new Point("charging_session")
.tag(TAG, session.installationId)
.tag("sessionId", session.sessionId)
.intField("endedAtMs", session.endedAt.getTime())
.floatField("durationSeconds", session.durationSeconds)
.floatField("energyKwh", session.energyKwh)
.floatField("chargingLossPercent", session.chargingLossPercent)
.floatField("gridEnergyKwh", session.gridEnergyKwh)
.floatField("pricePerKwh", session.pricePerKwh)
.floatField("totalCost", session.totalCost)
.booleanField("isDemo", session.isDemo === true)
.timestamp(session.startedAt);
if (session.startSocPercent !== undefined) summary.floatField("startSocPercent", session.startSocPercent);
if (session.endSocPercent !== undefined) summary.floatField("endSocPercent", session.endSocPercent);
const curve = session.curve.map((p) => {
const point = new Point("charging_curve")
.tag(TAG, session.installationId)
.tag("sessionId", session.sessionId)
.floatField("powerKw", p.powerKw)
.timestamp(p.timestamp);
if (p.socPercent !== undefined) point.floatField("socPercent", p.socPercent);
return point;
});
await write([summary, ...curve]);
}
export async function writeTelemetry(installationId: string, samples: TelemetrySample[]): Promise<void> {
const points = samples.map((sample) => {
const point = new Point("telemetry").tag(TAG, installationId).timestamp(new Date(sample.utc * 1000));
for (const field of Object.keys(TELEMETRY_NUMBERS) as (keyof typeof TELEMETRY_NUMBERS)[]) {
const value = sample[field];
if (value !== undefined) point.floatField(field, value);
}
for (const field of TELEMETRY_FLAGS) {
const value = sample[field];
if (value !== undefined) point.booleanField(field, value);
}
return point;
});
await write(points);
}
// ---------- Reads ----------
type Row = Record<string, unknown> & { _time: string };
async function rows(query: ReturnType<typeof flux>): Promise<Row[]> {
return queryApi().collectRows<Row>(query);
}
function num(row: Row, field: string): number | undefined {
const value = row[field];
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
export async function readConsumptionChunks(installationId: string): Promise<ConsumptionChunk[]> {
const result = await rows(flux`
from(bucket: ${bucket()})
|> range(start: 0)
|> filter(fn: (r) => r._measurement == "consumption_chunk" and r.installationId == ${installationId})
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")`);
return result.map((row) => {
const sohPercent = num(row, "sohPercent");
return {
installationId,
date: String(row.date),
recordedAt: new Date(row._time),
day: {
km: num(row, "km") ?? 0,
kwh: num(row, "kwh") ?? 0,
hours: num(row, "hours") ?? 0,
socDrop: num(row, "socDrop") ?? 0,
},
...(sohPercent === undefined ? {} : { sohPercent }),
};
});
}
export async function readDays(installationId: string): Promise<DailyConsumption[]> {
return aggregateDays(await readConsumptionChunks(installationId));
}
export async function readChargingSessions(installationId: string, limit: number): Promise<ChargingSession[]> {
const summaries = await rows(flux`
from(bucket: ${bucket()})
|> range(start: 0)
|> filter(fn: (r) => r._measurement == "charging_session" and r.installationId == ${installationId})
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|> group()
|> sort(columns: ["_time"], desc: true)
|> limit(n: ${limit})`);
if (summaries.length === 0) return [];
const sessions: ChargingSession[] = summaries.map((row) => {
const startSocPercent = num(row, "startSocPercent");
const endSocPercent = num(row, "endSocPercent");
return {
installationId,
sessionId: String(row.sessionId),
startedAt: new Date(row._time),
endedAt: new Date(num(row, "endedAtMs") ?? Date.parse(row._time)),
durationSeconds: num(row, "durationSeconds") ?? 0,
energyKwh: num(row, "energyKwh") ?? 0,
chargingLossPercent: num(row, "chargingLossPercent") ?? 10,
gridEnergyKwh: num(row, "gridEnergyKwh") ?? 0,
pricePerKwh: num(row, "pricePerKwh") ?? 0,
totalCost: num(row, "totalCost") ?? 0,
...(row.isDemo === true ? { isDemo: true } : {}),
...(startSocPercent === undefined ? {} : { startSocPercent }),
...(endSocPercent === undefined ? {} : { endSocPercent }),
curve: [],
};
});
// Curves for exactly these sessions: bounded by the oldest start and the newest end.
const start = new Date(Math.min(...sessions.map((s) => s.startedAt.getTime())));
const stop = new Date(Math.max(...sessions.map((s) => s.endedAt.getTime())) + 1000);
const curveRows = await rows(flux`
from(bucket: ${bucket()})
|> range(start: ${start}, stop: ${stop})
|> filter(fn: (r) => r._measurement == "charging_curve" and r.installationId == ${installationId})
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")`);
const bySession = new Map(sessions.map((s) => [s.sessionId, s]));
for (const row of curveRows) {
const session = bySession.get(String(row.sessionId));
const powerKw = num(row, "powerKw");
if (!session || powerKw === undefined) continue;
const socPercent = num(row, "socPercent");
const point: CurvePoint = { timestamp: new Date(row._time), powerKw };
if (socPercent !== undefined) point.socPercent = socPercent;
session.curve.push(point);
}
for (const session of sessions) session.curve.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
return sessions;
}
/** Installations that reported in the last 400 days, the most recent first. */
export async function installationIds(): Promise<string[]> {
const result = await rows(flux`
from(bucket: ${bucket()})
|> range(start: -400d)
|> filter(fn: (r) => r._measurement == "consumption_chunk" or r._measurement == "charging_session" or r._measurement == "telemetry")
|> keep(columns: ["_time", "installationId"])
|> group(columns: ["installationId"])
|> max(column: "_time")
|> group()
|> sort(columns: ["_time"], desc: true)`);
return result.map((row) => String(row.installationId));
}
/**
* The latest known value of every live field over the last 30 days, for the dashboard.
* Per field rather than per sample: the newest sample may lack a GPS fix that the one
* before it had. `at` is when the newest of them arrived. Null when nothing was sent.
*/
export async function readLatestTelemetry(installationId: string): Promise<(TelemetrySample & { at: Date }) | null> {
const result = await rows(flux`
from(bucket: ${bucket()})
|> range(start: -30d)
|> filter(fn: (r) => r._measurement == "telemetry" and r.installationId == ${installationId})
|> last()`);
if (result.length === 0) return null;
const newest = Math.max(...result.map((row) => Date.parse(row._time)));
const sample: TelemetrySample & { at: Date } = { utc: Math.floor(newest / 1000), at: new Date(newest) };
const numbers = TELEMETRY_NUMBERS as Record<string, unknown>;
const flags = TELEMETRY_FLAGS as readonly string[];
for (const row of result) {
const field = String(row._field);
const value = row._value;
if (field in numbers && typeof value === "number") (sample as Record<string, unknown>)[field] = value;
if (flags.includes(field) && typeof value === "boolean") (sample as Record<string, unknown>)[field] = value;
}
return sample;
}
+96
View File
@@ -0,0 +1,96 @@
/** Record shapes shared by validation, storage and the dashboard. */
export interface Totals {
km: number;
kwh: number;
hours: number;
socDrop: number;
}
/** One day's consumption: the sum of every chunk the car sent for that date. */
export interface DailyConsumption {
installationId: string;
/** The car's local date, YYYY-MM-DD. */
date: string;
/** When the newest chunk of the day was recorded. */
recordedAt: Date;
day: Totals;
/** Running total up to and including this day, derived when history is read. */
lifetime?: Totals;
sohPercent?: number;
}
/** One 2-minute delta chunk as the car sends it; days are built from these. */
export interface ConsumptionChunk {
installationId: string;
date: string;
recordedAt: Date;
day: Totals;
sohPercent?: number;
}
export interface CurvePoint {
timestamp: Date;
powerKw: number;
socPercent?: number;
}
export interface ChargingSession {
installationId: string;
sessionId: string;
startedAt: Date;
endedAt: Date;
durationSeconds: number;
energyKwh: number;
chargingLossPercent: number;
gridEnergyKwh: number;
pricePerKwh: number;
totalCost: number;
isDemo?: boolean;
startSocPercent?: number;
endSocPercent?: number;
curve: CurvePoint[];
}
/** Numeric live-telemetry fields, named and unit-ed as ABRP's tlm/send expects them. */
export const TELEMETRY_NUMBERS = {
soc: [0, 100], // %
speed: [0, 400], // km/h
power: [-1000, 1000], // kW, positive leaving the battery
est_battery_range: [0, 2000], // km
capacity: [0, 500], // kWh
soe: [0, 500], // kWh
soh: [0, 100], // %
kwh_charged: [0, 1000], // kWh, this charge session
odometer: [0, 10_000_000], // km
lat: [-90, 90],
lon: [-180, 180],
elevation: [-1000, 10_000], // m
heading: [0, 360], // °
batt_temp: [-60, 100], // °C
cabin_temp: [-60, 100], // °C
ext_temp: [-60, 70], // °C
hvac_setpoint: [0, 40], // °C
hvac_power: [0, 50], // kW
voltage: [0, 1500], // V
current: [-2000, 2000], // A
tire_pressure_fl: [0, 1000], // kPa
tire_pressure_fr: [0, 1000],
tire_pressure_rl: [0, 1000],
tire_pressure_rr: [0, 1000],
} as const satisfies Record<string, readonly [number, number]>;
/** Boolean live-telemetry fields; ABRP takes them as 0/1. */
export const TELEMETRY_FLAGS = ["is_charging", "is_dcfc", "is_parked"] as const;
export type TelemetryNumber = keyof typeof TELEMETRY_NUMBERS;
export type TelemetryFlag = (typeof TELEMETRY_FLAGS)[number];
/** One live sample. Only `utc` is always present; an unread signal is absent, never 0. */
export type TelemetrySample = { utc: number } & Partial<Record<TelemetryNumber, number>> &
Partial<Record<TelemetryFlag, boolean>>;
export interface TelemetryBatch {
installationId: string;
samples: TelemetrySample[];
}
+61 -10
View File
@@ -6,35 +6,40 @@
* read forever. Every number is bounded; an out-of-range value is rejected rather than * read forever. Every number is bounded; an out-of-range value is rejected rather than
* stored and quietly plotted. * stored and quietly plotted.
*/ */
import type { ChargingSession, CurvePoint, DailyConsumption, Totals } from "./mongo"; import {
TELEMETRY_FLAGS,
TELEMETRY_NUMBERS,
type ChargingSession,
type ConsumptionChunk,
type CurvePoint,
type TelemetryBatch,
type TelemetrySample,
type Totals,
} from "./types.ts";
export class ValidationError extends Error {} export class ValidationError extends Error {}
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
export type ConsumptionInput = Omit<DailyConsumption, "createdAt" | "updatedAt">; export function validateConsumption(body: unknown): ConsumptionChunk {
export type ChargingInput = Omit<ChargingSession, "createdAt" | "updatedAt">;
export function validateConsumption(body: unknown): ConsumptionInput {
const record = asObject(body); const record = asObject(body);
const date = record.date; const date = record.date;
if (typeof date !== "string" || !ISO_DATE.test(date)) throw new ValidationError("date is invalid"); if (typeof date !== "string" || !ISO_DATE.test(date)) throw new ValidationError("date is invalid");
const sohPercent = record.sohPercent; const sohPercent = record.sohPercent;
const lifetime = record.lifetime; // A lifetime from an older app is still validated, so a malformed one is still a 400,
// but it is not stored: the backend derives lifetime from the day totals.
if (record.lifetime !== undefined) validateTotals(record.lifetime, "lifetime");
return { return {
installationId: validateUuid(record.installationId, "installationId"), installationId: validateUuid(record.installationId, "installationId"),
date, date,
recordedAt: validateDate(record.recordedAt, "recordedAt"), recordedAt: validateDate(record.recordedAt, "recordedAt"),
day: validateTotals(record.day, "day"), day: validateTotals(record.day, "day"),
// Lifetime is optional — the backend derives it from day totals, and the app no longer
// sends it. Legacy documents may still carry one; new chunks do not.
...(lifetime === undefined ? {} : { lifetime: validateTotals(lifetime, "lifetime") }),
...(sohPercent === undefined ? {} : { sohPercent: bounded(sohPercent, 0, 100, "sohPercent") }), ...(sohPercent === undefined ? {} : { sohPercent: bounded(sohPercent, 0, 100, "sohPercent") }),
}; };
} }
export function validateChargingSession(body: unknown, pathSessionId: string): ChargingInput { export function validateChargingSession(body: unknown, pathSessionId: string): ChargingSession {
const record = asObject(body); const record = asObject(body);
const sessionId = validateUuid(record.sessionId, "sessionId"); const sessionId = validateUuid(record.sessionId, "sessionId");
if (sessionId !== pathSessionId) throw new ValidationError("sessionId does not match path"); if (sessionId !== pathSessionId) throw new ValidationError("sessionId does not match path");
@@ -75,6 +80,52 @@ export function validateChargingSession(body: unknown, pathSessionId: string): C
}; };
} }
const MAX_SAMPLES = 500;
/** Epoch seconds: 2001 to 2286. Milliseconds would be far above the upper bound. */
const UTC_MIN = 1_000_000_000;
const UTC_MAX = 9_999_999_999;
/**
* A batch of live samples in ABRP's tlm field names and units. Fields the server does not
* know are dropped rather than rejected, so a newer app can add one without a server
* release; every known field is bounded, and a flag may be sent as 0/1 or a boolean.
*/
export function validateTelemetryBatch(body: unknown): TelemetryBatch {
const record = asObject(body);
const installationId = validateUuid(record.installationId, "installationId");
const samples = record.samples;
if (!Array.isArray(samples) || samples.length === 0 || samples.length > MAX_SAMPLES) {
throw new ValidationError("samples is invalid");
}
return { installationId, samples: samples.map((entry, i) => validateSample(entry, `samples[${i}]`)) };
}
function validateSample(entry: unknown, name: string): TelemetrySample {
const raw = asObject(entry);
const utc = raw.utc;
if (typeof utc !== "number" || !Number.isSafeInteger(utc) || utc < UTC_MIN || utc > UTC_MAX) {
throw new ValidationError(`${name}.utc is invalid`);
}
const sample: TelemetrySample = { utc };
for (const [field, [min, max]] of Object.entries(TELEMETRY_NUMBERS)) {
const value = raw[field];
if (value === undefined || value === null) continue;
(sample as Record<string, unknown>)[field] = bounded(value, min, max, `${name}.${field}`);
}
for (const field of TELEMETRY_FLAGS) {
const value = raw[field];
if (value === undefined || value === null) continue;
if (value === true || value === 1) sample[field] = true;
else if (value === false || value === 0) sample[field] = false;
else throw new ValidationError(`${name}.${field} is invalid`);
}
// A position is only a position as a pair.
if ((sample.lat === undefined) !== (sample.lon === undefined)) {
throw new ValidationError(`${name}.lat and lon must be sent together`);
}
return sample;
}
export function validateUuid(value: unknown, name: string): string { export function validateUuid(value: unknown, name: string): string {
if (typeof value !== "string" || !UUID.test(value)) throw new ValidationError(`${name} is invalid`); if (typeof value !== "string" || !UUID.test(value)) throw new ValidationError(`${name} is invalid`);
return value; return value;
+37
View File
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { aggregateDays } from "../src/lib/days.ts";
const ID = "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b";
const chunk = (date: string, at: number, km: number, kwh: number, sohPercent?: number) => ({
installationId: ID,
date,
recordedAt: new Date(at),
day: { km, kwh, hours: km / 50, socDrop: kwh / 0.6 },
...(sohPercent === undefined ? {} : { sohPercent }),
});
test("adds chunks up per day, newest day first, with a running lifetime", () => {
const days = aggregateDays([
chunk("2026-10-16", 1_000, 10, 2),
chunk("2026-10-17", 3_000, 5, 1),
chunk("2026-10-16", 2_000, 20, 3),
]);
assert.deepEqual(days.map((d) => d.date), ["2026-10-17", "2026-10-16"]);
assert.equal(days[1].day.km, 30);
assert.equal(days[1].day.kwh, 5);
assert.equal(days[1].recordedAt.getTime(), 2_000);
assert.equal(days[1].lifetime?.km, 30);
assert.equal(days[0].lifetime?.km, 35);
assert.equal(days[0].lifetime?.kwh, 6);
});
test("the newest manual SoH of a day wins, and a day without one has none", () => {
const days = aggregateDays([
chunk("2026-10-16", 2_000, 1, 1, 96),
chunk("2026-10-16", 1_000, 1, 1, 98),
chunk("2026-10-17", 3_000, 1, 1),
]);
assert.equal(days[1].sohPercent, 96);
assert.equal("sohPercent" in days[0], false);
});
+56
View File
@@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { pickForForward, resetAbrpState, toTlm } from "../src/lib/abrp.ts";
import { ValidationError, validateTelemetryBatch } from "../src/lib/validate.ts";
const INSTALLATION = "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b";
const NOW_S = 1_790_000_000;
test("accepts a sample in ABRP's field names and keeps only what was sent", () => {
const batch = validateTelemetryBatch({
installationId: INSTALLATION,
samples: [{ utc: NOW_S, soc: 81, power: -12.5, speed: 0, lat: 52.1, lon: 7.6, is_charging: 1, is_dcfc: false }],
});
assert.deepEqual(batch.samples[0], {
utc: NOW_S, soc: 81, power: -12.5, speed: 0, lat: 52.1, lon: 7.6, is_charging: true, is_dcfc: false,
});
// An unread signal stays absent: a missing SOC must never become 0.
assert.equal("est_battery_range" in batch.samples[0], false);
});
test("drops unknown fields instead of rejecting the batch", () => {
const batch = validateTelemetryBatch({ installationId: INSTALLATION, samples: [{ utc: NOW_S, future_field: 3 }] });
assert.deepEqual(batch.samples[0], { utc: NOW_S });
});
test("rejects bad timestamps, out-of-range values, bad flags and half positions", () => {
const send = (sample: object) => validateTelemetryBatch({ installationId: INSTALLATION, samples: [sample] });
assert.throws(() => send({ utc: NOW_S * 1000 }), ValidationError); // milliseconds, not seconds
assert.throws(() => send({ utc: 1.5 }), ValidationError);
assert.throws(() => send({ utc: NOW_S, soc: 101 }), ValidationError);
assert.throws(() => send({ utc: NOW_S, speed: "fast" }), ValidationError);
assert.throws(() => send({ utc: NOW_S, is_parked: 2 }), ValidationError);
assert.throws(() => send({ utc: NOW_S, lat: 52.1 }), ValidationError);
});
test("rejects empty and oversized batches and bad installation ids", () => {
assert.throws(() => validateTelemetryBatch({ installationId: INSTALLATION, samples: [] }), ValidationError);
const many = Array.from({ length: 501 }, (_, i) => ({ utc: NOW_S + i }));
assert.throws(() => validateTelemetryBatch({ installationId: INSTALLATION, samples: many }), ValidationError);
assert.throws(() => validateTelemetryBatch({ installationId: "nope", samples: [{ utc: NOW_S }] }), ValidationError);
});
test("ABRP gets flags as 0/1", () => {
assert.deepEqual(toTlm({ utc: NOW_S, soc: 50, is_charging: true, is_parked: false }), {
utc: NOW_S, soc: 50, is_charging: 1, is_parked: 0,
});
});
test("forwards only the newest fresh sample, at most once per interval", () => {
resetAbrpState();
const nowMs = NOW_S * 1000;
const picked = pickForForward([{ utc: NOW_S - 10 }, { utc: NOW_S - 2 }, { utc: NOW_S - 5 }], nowMs);
assert.equal(picked?.utc, NOW_S - 2);
// A sample from ten minutes ago describes a car that has moved on.
assert.equal(pickForForward([{ utc: NOW_S - 600 }], nowMs), null);
});
+1
View File
@@ -6,6 +6,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"noEmit": true, "noEmit": true,
"allowImportingTsExtensions": true,
"esModuleInterop": true, "esModuleInterop": true,
"module": "esnext", "module": "esnext",
"moduleResolution": "bundler", "moduleResolution": "bundler",