Rename app and persist all-time consumption to MongoDB

This commit is contained in:
TCFDONMEZ
2026-10-08 13:23:00 +03:00
parent 7366ba0480
commit 9a6c121d09
22 changed files with 1449 additions and 30 deletions
+4
View File
@@ -0,0 +1,4 @@
MONGODB_URI=mongodb+srv://username:password@cluster.example.mongodb.net/
MONGODB_DATABASE=fatihsmg4
API_TOKEN=replace-with-a-long-random-secret
PORT=8080
+3
View File
@@ -0,0 +1,3 @@
node_modules/
.env
npm-debug.log*
+9
View File
@@ -0,0 +1,9 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
ENV NODE_ENV=production
USER node
EXPOSE 8080
CMD ["node", "src/server.js"]
+21
View File
@@ -0,0 +1,21 @@
# 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.
## Run
1. Copy `.env.example` to `.env` and fill in the MongoDB Atlas URI and a random API token.
2. Export the variables (Node does not load `.env` automatically), then run `npm install` and
`npm start`.
3. Build the Android app with the public HTTPS service address and the same token:
```sh
CONSUMPTION_API_URL=https://your-api.example.com \
CONSUMPTION_API_TOKEN=your-random-token \
./gradlew assembleUnstableRelease
```
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`.
+1040
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
"name": "fatihsmg4-consumption-api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/server.js",
"test": "node --test"
},
"engines": {
"node": ">=22"
},
"dependencies": {
"express": "^5.1.0",
"mongodb": "^6.20.0"
}
}
+100
View File
@@ -0,0 +1,100 @@
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");
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.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.use((error, _request, response, _next) => {
if (error?.type === "entity.parse.failed" || error?.type === "entity.too.large") {
response.status(400).json({ error: "invalid JSON body" });
return;
}
console.error("Request failed", error);
response.status(500).json({ error: "internal server error" });
});
return app;
}
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;
}
next();
};
}
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");
}
return {
installationId: body.installationId,
recordedAt: new Date(body.recordedAt),
lifetime: validateTotals(body.lifetime, "lifetime"),
...(body.sohPercent === undefined ? {} : { sohPercent: validateSoh(body.sohPercent) })
};
}
function validateSoh(value) {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > 100) {
throw new ValidationError("sohPercent 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) {
throw new ValidationError(`${name}.${field} is invalid`);
}
result[field] = number;
}
return result;
}
+28
View File
@@ -0,0 +1,28 @@
import { MongoClient } from "mongodb";
import { createApp } from "./app.js";
const { MONGODB_URI, API_TOKEN } = process.env;
const databaseName = process.env.MONGODB_DATABASE || "fatihsmg4";
const port = Number(process.env.PORT || 8080);
if (!MONGODB_URI) throw new Error("MONGODB_URI is required");
if (!API_TOKEN) throw new Error("API_TOKEN is required");
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("PORT is invalid");
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 app = createApp({ collection, apiToken: API_TOKEN });
const server = app.listen(port, () => console.log(`FatihsMG4 API listening on ${port}`));
async function shutdown() {
server.close(async () => {
await client.close();
process.exit(0);
});
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
+52
View File
@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
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;
before(async () => {
const app = createApp({ collection, 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
};
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" } });
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 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)
});
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);
});