Track charging sessions and daily consumption history

This commit is contained in:
TCFDONMEZ
2026-10-08 13:39:40 +03:00
parent 9a6c121d09
commit 1a37e52eda
13 changed files with 527 additions and 129 deletions
+9 -3
View File
@@ -1,7 +1,8 @@
# FatihsMG4 consumption API
The Android app never connects to MongoDB directly and never contains a MongoDB URI. This
service accepts authenticated all-time totals and upserts them into the `consumption` collection.
service stores completed daily totals in `daily_consumption` and each completed charge,
including its SOC/power curve and price, in `charging_sessions`.
## Run
@@ -17,5 +18,10 @@ service accepts authenticated all-time totals and upserts them into the `consump
```
If either Android build variable is missing, cloud sync is disabled and local consumption
tracking continues unchanged. MongoDB receives one continuously updated document per installation,
containing all-time `km`, `kwh`, `hours`, and `socDrop` totals plus the manually entered `sohPercent`.
tracking continues unchanged. The car retries locally queued charging sessions every five minutes.
Daily consumption is uploaded once after the day closes; missed days are retried in order.
The future mobile client can read authenticated history from:
- `GET /v1/consumption/:installationId`
- `GET /v1/charging-sessions/:installationId`
+82 -52
View File
@@ -1,43 +1,47 @@
import crypto from "node:crypto";
import express from "express";
const MAX_BODY_BYTES = "16kb";
export function createApp({ collection, apiToken }) {
if (!collection) throw new Error("collection is required");
export function createApp({ consumptionCollection, chargingCollection, apiToken }) {
if (!consumptionCollection || !chargingCollection) throw new Error("collections are required");
if (!apiToken || apiToken.length < 24) throw new Error("API_TOKEN must contain at least 24 characters");
const app = express();
app.disable("x-powered-by");
app.use(express.json({ limit: MAX_BODY_BYTES, strict: true }));
app.use(express.json({ limit: "256kb", strict: true }));
const auth = authenticate(apiToken);
app.get("/health", (_request, response) => response.json({ status: "ok" }));
app.put("/v1/consumption", authenticate(apiToken), async (request, response, next) => {
try {
const record = validateRecord(request.body);
const updatedAt = new Date();
await collection.updateOne(
{ installationId: record.installationId },
{
$set: { ...record, updatedAt },
$setOnInsert: { createdAt: updatedAt }
},
{ upsert: true }
);
response.status(204).end();
} catch (error) {
if (error instanceof ValidationError) {
response.status(400).json({ error: error.message });
return;
}
next(error);
}
});
app.put("/v1/consumption/daily", auth, route(async (request, response) => {
const record = validateConsumption(request.body);
const updatedAt = new Date();
await consumptionCollection.updateOne(
{ installationId: record.installationId, date: record.date },
{ $set: { ...record, updatedAt }, $setOnInsert: { createdAt: updatedAt } }, { upsert: true });
response.status(204).end();
}));
app.put("/v1/charging-sessions/:sessionId", auth, route(async (request, response) => {
const record = validateChargingSession(request.body, request.params.sessionId);
const updatedAt = new Date();
await chargingCollection.updateOne(
{ installationId: record.installationId, sessionId: record.sessionId },
{ $set: { ...record, updatedAt }, $setOnInsert: { createdAt: updatedAt } }, { upsert: true });
response.status(204).end();
}));
app.get("/v1/consumption/:installationId", auth, route(async (request, response) => {
const installationId = validateUuid(request.params.installationId, "installationId");
const records = await consumptionCollection.find({ installationId }).sort({ date: -1 }).limit(400).toArray();
response.json({ records });
}));
app.get("/v1/charging-sessions/:installationId", auth, route(async (request, response) => {
const installationId = validateUuid(request.params.installationId, "installationId");
const records = await chargingCollection.find({ installationId }).sort({ startedAt: -1 }).limit(200).toArray();
response.json({ records });
}));
app.use((error, _request, response, _next) => {
if (error?.type === "entity.parse.failed" || error?.type === "entity.too.large") {
response.status(400).json({ error: "invalid JSON body" });
if (error instanceof ValidationError || error?.type === "entity.parse.failed" || error?.type === "entity.too.large") {
response.status(400).json({ error: error.message || "invalid request" });
return;
}
console.error("Request failed", error);
@@ -46,16 +50,16 @@ export function createApp({ collection, apiToken }) {
return app;
}
function route(handler) { return (request, response, next) => Promise.resolve(handler(request, response)).catch(next); }
function authenticate(expected) {
const expectedBytes = Buffer.from(expected);
return (request, response, next) => {
const header = request.get("authorization") ?? "";
const supplied = header.startsWith("Bearer ") ? header.slice(7) : "";
const suppliedBytes = Buffer.from(supplied);
if (suppliedBytes.length !== expectedBytes.length ||
!crypto.timingSafeEqual(suppliedBytes, expectedBytes)) {
response.status(401).json({ error: "unauthorized" });
return;
if (suppliedBytes.length !== expectedBytes.length || !crypto.timingSafeEqual(suppliedBytes, expectedBytes)) {
response.status(401).json({ error: "unauthorized" }); return;
}
next();
};
@@ -63,37 +67,63 @@ function authenticate(expected) {
class ValidationError extends Error {}
function validateRecord(body) {
if (!body || typeof body !== "object" || Array.isArray(body)) throw new ValidationError("body is required");
if (!/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(body.installationId ?? "")) {
throw new ValidationError("installationId is invalid");
}
if (!Number.isSafeInteger(body.recordedAt) || body.recordedAt < 0) {
throw new ValidationError("recordedAt is invalid");
}
function validateConsumption(body) {
validateBody(body);
if (!/^\d{4}-\d{2}-\d{2}$/.test(body.date ?? "")) throw new ValidationError("date is invalid");
return {
installationId: body.installationId,
recordedAt: new Date(body.recordedAt),
installationId: validateUuid(body.installationId, "installationId"), date: body.date,
recordedAt: validateDate(body.recordedAt, "recordedAt"), day: validateTotals(body.day, "day"),
lifetime: validateTotals(body.lifetime, "lifetime"),
...(body.sohPercent === undefined ? {} : { sohPercent: validateSoh(body.sohPercent) })
...(body.sohPercent === undefined ? {} : { sohPercent: bounded(body.sohPercent, 0, 100, "sohPercent") })
};
}
function validateSoh(value) {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > 100) {
throw new ValidationError("sohPercent is invalid");
}
return value;
function validateChargingSession(body, pathSessionId) {
validateBody(body);
const sessionId = validateUuid(body.sessionId, "sessionId");
if (sessionId !== pathSessionId) throw new ValidationError("sessionId does not match path");
if (!Array.isArray(body.curve) || body.curve.length > 720) throw new ValidationError("curve is invalid");
const curve = body.curve.map((point) => ({
timestamp: validateDate(point.timestampMs, "curve.timestampMs"),
powerKw: bounded(point.powerKw, 0, 1000, "curve.powerKw"),
...(point.socPercent === undefined ? {} : { socPercent: bounded(point.socPercent, 0, 100, "curve.socPercent") })
}));
return {
installationId: validateUuid(body.installationId, "installationId"), sessionId,
startedAt: validateDate(body.startedAt, "startedAt"), endedAt: validateDate(body.endedAt, "endedAt"),
durationSeconds: bounded(body.durationSeconds, 0, 604800, "durationSeconds"),
energyKwh: bounded(body.energyKwh, 0, 1000, "energyKwh"),
pricePerKwh: bounded(body.pricePerKwh, 0, 1_000_000, "pricePerKwh"),
totalCost: bounded(body.totalCost, 0, 1_000_000_000, "totalCost"),
...(body.startSocPercent === undefined ? {} : { startSocPercent: bounded(body.startSocPercent, 0, 100, "startSocPercent") }),
...(body.endSocPercent === undefined ? {} : { endSocPercent: bounded(body.endSocPercent, 0, 100, "endSocPercent") }), curve
};
}
function validateBody(body) {
if (!body || typeof body !== "object" || Array.isArray(body)) throw new ValidationError("body is required");
}
function validateUuid(value, name) {
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value ?? ""))
throw new ValidationError(`${name} is invalid`);
return value;
}
function validateDate(value, name) {
if (!Number.isSafeInteger(value) || value < 0) throw new ValidationError(`${name} is invalid`);
return new Date(value);
}
function bounded(value, min, max, name) {
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max)
throw new ValidationError(`${name} is invalid`);
return value;
}
function validateTotals(value, name) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new ValidationError(`${name} is invalid`);
const result = {};
for (const field of ["km", "kwh", "hours", "socDrop"]) {
const number = value[field];
if (typeof number !== "number" || !Number.isFinite(number) || Math.abs(number) > 10_000_000) {
if (typeof number !== "number" || !Number.isFinite(number) || Math.abs(number) > 10_000_000)
throw new ValidationError(`${name}.${field} is invalid`);
}
result[field] = number;
}
return result;
+8 -4
View File
@@ -11,11 +11,15 @@ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("PORT i
const client = new MongoClient(MONGODB_URI, { serverSelectionTimeoutMS: 10_000 });
await client.connect();
const collection = client.db(databaseName).collection("consumption");
await collection.createIndex({ installationId: 1 }, { unique: true });
await collection.createIndex({ updatedAt: -1 });
const database = client.db(databaseName);
const consumptionCollection = database.collection("daily_consumption");
const chargingCollection = database.collection("charging_sessions");
await consumptionCollection.createIndex({ installationId: 1, date: 1 }, { unique: true });
await consumptionCollection.createIndex({ installationId: 1, date: -1 });
await chargingCollection.createIndex({ installationId: 1, sessionId: 1 }, { unique: true });
await chargingCollection.createIndex({ installationId: 1, startedAt: -1 });
const app = createApp({ collection, apiToken: API_TOKEN });
const app = createApp({ consumptionCollection, chargingCollection, apiToken: API_TOKEN });
const server = app.listen(port, () => console.log(`FatihsMG4 API listening on ${port}`));
async function shutdown() {
+36 -31
View File
@@ -3,50 +3,55 @@ import { after, before, test } from "node:test";
import { createApp } from "../src/app.js";
const token = "a-secure-test-token-with-24-chars";
const writes = [];
const collection = {
async updateOne(filter, update, options) { writes.push({ filter, update, options }); }
};
let server;
let baseUrl;
const consumptionWrites = [], chargingWrites = [];
const readable = (writes) => ({
async updateOne(filter, update, options) { writes.push({ filter, update, options }); },
find() { return { sort() { return this; }, limit() { return this; }, async toArray() { return []; } }; }
});
let server, baseUrl;
before(async () => {
const app = createApp({ collection, apiToken: token });
const app = createApp({ consumptionCollection: readable(consumptionWrites),
chargingCollection: readable(chargingWrites), apiToken: token });
await new Promise((resolve) => { server = app.listen(0, "127.0.0.1", resolve); });
baseUrl = `http://127.0.0.1:${server.address().port}`;
});
after(() => new Promise((resolve) => server.close(resolve)));
const validBody = {
installationId: "123e4567-e89b-12d3-a456-426614174000",
recordedAt: 1791417600000,
lifetime: { km: 1000, kwh: 170, hours: 30, socDrop: 400 },
sohPercent: 91.5
};
const auth = { "content-type": "application/json", authorization: `Bearer ${token}` };
const installationId = "123e4567-e89b-42d3-a456-426614174000";
const consumption = { installationId, recordedAt: 1791417600000, date: "2026-10-08",
day: { km: 12, kwh: 2.1, hours: .4, socDrop: 4 },
lifetime: { km: 1000, kwh: 170, hours: 30, socDrop: 400 }, sohPercent: 91.5 };
test("rejects requests without the API token", async () => {
const response = await fetch(`${baseUrl}/v1/consumption`, { method: "PUT", body: JSON.stringify(validBody), headers: { "content-type": "application/json" } });
const response = await fetch(`${baseUrl}/v1/consumption/daily`, { method: "PUT", body: JSON.stringify(consumption), headers: { "content-type": "application/json" } });
assert.equal(response.status, 401);
});
test("validates malformed all-time totals", async () => {
const response = await fetch(`${baseUrl}/v1/consumption`, {
method: "PUT",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ ...validBody, lifetime: { ...validBody.lifetime, km: "12" } })
});
assert.equal(response.status, 400);
test("upserts one daily consumption record", async () => {
const response = await fetch(`${baseUrl}/v1/consumption/daily`, { method: "PUT", headers: auth, body: JSON.stringify(consumption) });
assert.equal(response.status, 204);
assert.deepEqual(consumptionWrites.at(-1).filter, { installationId, date: "2026-10-08" });
assert.equal(consumptionWrites.at(-1).update.$set.day.kwh, 2.1);
});
test("upserts one all-time record per installation", async () => {
const response = await fetch(`${baseUrl}/v1/consumption`, {
method: "PUT",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify(validBody)
});
test("upserts a complete charging session with its curve and price", async () => {
const sessionId = "223e4567-e89b-42d3-a456-426614174001";
const session = { installationId, sessionId, startedAt: 1791417600000, endedAt: 1791421200000,
durationSeconds: 3600, energyKwh: 50, pricePerKwh: 8.5, totalCost: 425,
startSocPercent: 10, endSocPercent: 90,
curve: [{ timestampMs: 1791417600000, socPercent: 10, powerKw: 72 }] };
const response = await fetch(`${baseUrl}/v1/charging-sessions/${sessionId}`,
{ method: "PUT", headers: auth, body: JSON.stringify(session) });
assert.equal(response.status, 204);
assert.deepEqual(writes.at(-1).filter, { installationId: validBody.installationId });
assert.equal(writes.at(-1).options.upsert, true);
assert.equal(writes.at(-1).update.$set.lifetime.kwh, 170);
assert.equal(writes.at(-1).update.$set.sohPercent, 91.5);
assert.deepEqual(chargingWrites.at(-1).filter, { installationId, sessionId });
assert.equal(chargingWrites.at(-1).update.$set.totalCost, 425);
assert.equal(chargingWrites.at(-1).update.$set.curve[0].powerKw, 72);
});
test("read APIs are authenticated and mobile-client ready", async () => {
const response = await fetch(`${baseUrl}/v1/charging-sessions/${installationId}`, { headers: { authorization: `Bearer ${token}` } });
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { records: [] });
});