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:
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user