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,6 +1,6 @@
import { NextResponse } from "next/server";
import { handle, parseJson, requireIngest } from "@/lib/api";
import { chargingCollection, ensureIndexes } from "@/lib/mongo";
import { writeChargingSession } from "@/lib/store";
import { validateChargingSession } from "@/lib/validate";
export const runtime = "nodejs";
@@ -11,15 +11,7 @@ export async function PUT(request: Request, context: { params: Promise<{ session
if (denied) return denied;
return handle(async () => {
const { sessionId } = await context.params;
const record = 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 },
);
await writeChargingSession(validateChargingSession(await parseJson(request), sessionId));
return new NextResponse(null, { status: 204 });
});
}
+5 -32
View File
@@ -1,48 +1,21 @@
import { NextResponse } from "next/server";
import { handle, parseJson, requireIngest } from "@/lib/api";
import { consumptionCollection, ensureIndexes } from "@/lib/mongo";
import { writeConsumptionChunk } from "@/lib/store";
import { validateConsumption } from "@/lib/validate";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Upsert on (installationId, date), incrementally. The car sends a 2-minute delta chunk
* (not a cumulative total), so each PUT adds to the day's running totals rather than
* overwriting them. The app does NOT send lifetime — the backend derives it as the sum of
* 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.
* One delta chunk (not a cumulative total) of a day's consumption. Stored as its own point
* at its recordedAt; reads add a day's chunks up. A chunk the car re-sends from its offline
* cache carries the same recordedAt and replaces itself, so a retry is never counted twice.
*/
export async function PUT(request: Request) {
const denied = requireIngest(request);
if (denied) return denied;
return handle(async () => {
const record = 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 },
);
await writeConsumptionChunk(validateConsumption(await parseJson(request)));
return new NextResponse(null, { status: 204 });
});
}
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { handle, requireIngest } from "@/lib/api";
import { consumptionCollection } from "@/lib/mongo";
import { readDays } from "@/lib/store";
import { validateUuid } from "@/lib/validate";
export const runtime = "nodejs";
@@ -21,23 +21,10 @@ export async function GET(request: Request, context: { params: Promise<{ install
if (denied) return denied;
return handle(async () => {
const { installationId } = await context.params;
const collection = await consumptionCollection();
const records = await collection
.find({ installationId: validateUuid(installationId, "installationId") }, { projection: { _id: 0 } })
.sort({ date: -1 })
.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 },
);
const days = await readDays(validateUuid(installationId, "installationId"));
// Lifetime = sum of every day's totals, which is the newest day's running total.
const lifetime = days[0]?.lifetime ?? { km: 0, kwh: 0, hours: 0, socDrop: 0 };
const records = days.slice(0, 400).map(({ lifetime: _lifetime, ...day }) => day);
return NextResponse.json({ records, lifetime });
});
}
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { handle, requireReader } from "@/lib/api";
import { chargingCollection } from "@/lib/mongo";
import { readChargingSessions } from "@/lib/store";
import { validateUuid } from "@/lib/validate";
export const runtime = "nodejs";
@@ -11,12 +11,7 @@ export async function GET(request: Request, context: { params: Promise<{ install
if (denied) return denied;
return handle(async () => {
const { installationId } = await context.params;
const collection = await chargingCollection();
const records = await collection
.find({ installationId: validateUuid(installationId, "installationId") }, { projection: { _id: 0 } })
.sort({ startedAt: -1 })
.limit(200)
.toArray();
const records = await readChargingSessions(validateUuid(installationId, "installationId"), 200);
return NextResponse.json({ records });
});
}
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { handle, requireReader } from "@/lib/api";
import { consumptionCollection } from "@/lib/mongo";
import { readDays } from "@/lib/store";
import { validateUuid } from "@/lib/validate";
export const runtime = "nodejs";
@@ -11,12 +11,7 @@ export async function GET(request: Request, context: { params: Promise<{ install
if (denied) return denied;
return handle(async () => {
const { installationId } = await context.params;
const collection = await consumptionCollection();
const records = await collection
.find({ installationId: validateUuid(installationId, "installationId") }, { projection: { _id: 0 } })
.sort({ date: -1 })
.limit(400)
.toArray();
const records = (await readDays(validateUuid(installationId, "installationId"))).slice(0, 400);
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 });
});
}