diff --git a/.gitignore b/.gitignore index aab93a6..90dee08 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,7 @@ mise.local.toml /EVHardware/build /EVHardware/.gradle /EVHardware/**/build + +# Filled-in Kubernetes Secrets (copy of k8s/secrets/secrets.example.yaml). Never commit. +/k8s/secrets.yaml +/k8s/secrets/secrets.yaml diff --git a/.next/cache/_events_79120_d0eaad9b80692b5d.json b/.next/cache/_events_79120_d0eaad9b80692b5d.json deleted file mode 100644 index a25504d..0000000 --- a/.next/cache/_events_79120_d0eaad9b80692b5d.json +++ /dev/null @@ -1 +0,0 @@ -[{"eventName":"NEXT_CLI_SESSION_STOPPED","payload":{"nextVersion":"16.4.0","nodeVersion":"v24.12.0","cliCommand":"dev","durationMilliseconds":348,"turboFlag":true,"pagesDir":false,"appDir":true,"isRspack":false}}] \ No newline at end of file diff --git a/.next/cache/_events_79235_382896d082e25d69.json b/.next/cache/_events_79235_382896d082e25d69.json deleted file mode 100644 index 24ede18..0000000 --- a/.next/cache/_events_79235_382896d082e25d69.json +++ /dev/null @@ -1 +0,0 @@ -[{"eventName":"NEXT_CLI_SESSION_STOPPED","payload":{"nextVersion":"16.4.0","nodeVersion":"v24.12.0","cliCommand":"dev","durationMilliseconds":345,"turboFlag":true,"pagesDir":false,"appDir":true,"isRspack":false}}] \ No newline at end of file diff --git a/.next/dev/build-manifest.json b/.next/dev/build-manifest.json deleted file mode 100644 index cc102f9..0000000 --- a/.next/dev/build-manifest.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "pages": { - "/_app": [] - }, - "devFiles": [], - "polyfillFiles": [], - "lowPriorityFiles": [ - "static/development/_buildManifest.js", - "static/development/_ssgManifest.js", - "static/development/_clientMiddlewareManifest.js" - ], - "rootMainFiles": [], - "rootMainFilesTree": {}, - "pagesChunkGroupBootstrapParams": {} -} \ No newline at end of file diff --git a/.next/dev/cache/.rscinfo b/.next/dev/cache/.rscinfo deleted file mode 100644 index 4830a4e..0000000 --- a/.next/dev/cache/.rscinfo +++ /dev/null @@ -1 +0,0 @@ -{"encryption.key":"UT9PfJliySgklhhPCIM4wpEhmx9PMD9GPB4iuPIamyM=","encryption.expire_at":1792761221411} \ No newline at end of file diff --git a/.next/dev/cache/turbopack/v16.4.0-e273d5b2/CURRENT b/.next/dev/cache/turbopack/v16.4.0-e273d5b2/CURRENT deleted file mode 100644 index 5f9b019..0000000 --- a/.next/dev/cache/turbopack/v16.4.0-e273d5b2/CURRENT +++ /dev/null @@ -1 +0,0 @@ -{"max_sequence_number":0,"commit_time":"2026-10-09T13:13:41.418251Z"} diff --git a/.next/dev/fallback-build-manifest.json b/.next/dev/fallback-build-manifest.json deleted file mode 100644 index cc102f9..0000000 --- a/.next/dev/fallback-build-manifest.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "pages": { - "/_app": [] - }, - "devFiles": [], - "polyfillFiles": [], - "lowPriorityFiles": [ - "static/development/_buildManifest.js", - "static/development/_ssgManifest.js", - "static/development/_clientMiddlewareManifest.js" - ], - "rootMainFiles": [], - "rootMainFilesTree": {}, - "pagesChunkGroupBootstrapParams": {} -} \ No newline at end of file diff --git a/.next/dev/lock b/.next/dev/lock deleted file mode 100644 index faa33e6..0000000 --- a/.next/dev/lock +++ /dev/null @@ -1 +0,0 @@ -{"pid":79123,"port":3001,"hostname":"localhost","appUrl":"http://localhost:3001","startedAt":1791551621304} \ No newline at end of file diff --git a/.next/dev/logs/next-development.log b/.next/dev/logs/next-development.log deleted file mode 100644 index 2cc714a..0000000 --- a/.next/dev/logs/next-development.log +++ /dev/null @@ -1,3 +0,0 @@ -{"timestamp":"00:00:01.802","source":"Server","level":"ERROR","message":"Turbopack build encountered 1 error:\n./app\nError: Could not find the Next.js package (next/package.json)\nResolved from: /Users/tcfdonmez/Desktop/Coding/MG/EVABRPUploader/app\nFilesystem root used for resolution: /Users/tcfdonmez/Desktop/Coding/MG/EVABRPUploader\n\nPossible causes:\n - node_modules is being reorganized by a concurrent install (e.g. pnpm adding a package with a `next` peer dependency). This is transient and should clear once the install completes.\n - node_modules/next was removed, renamed, or has a broken symlink.\n - The workspace root is incorrect — see turbopack.root in the Next.js config docs for how to configure it.\n - In a monorepo, the Next.js package may only exist in a directory above the closest directory containing a package manager lockfile. The workspace root is detected by locating the nearest package manager lockfile.\n - Next.js is installed globally rather than as a project dependency. This is not supported; install it locally.\n\nNote: To ensure a hermetic build and a portable cache, files outside of the workspace root are not compiled.\n\nhttps://nextjs.org/docs/app/api-reference/config/next-config-js/turbopack#root-directory\n\n"} -{"timestamp":"00:00:02.079","source":"Server","level":"LOG","message":""} -{"timestamp":"00:00:02.080","source":"Server","level":"LOG","message":"✓ Generated AGENTS.md for AI agents. Set `agentRules: false` in next.config to disable."} diff --git a/.next/dev/package.json b/.next/dev/package.json deleted file mode 100644 index c9a4422..0000000 --- a/.next/dev/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "commonjs" -} \ No newline at end of file diff --git a/.next/dev/prerender-manifest.json b/.next/dev/prerender-manifest.json deleted file mode 100644 index 4400a7a..0000000 --- a/.next/dev/prerender-manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "version": 4, - "routes": {}, - "dynamicRoutes": {}, - "notFoundRoutes": [] -} \ No newline at end of file diff --git a/.next/dev/routes-manifest.json b/.next/dev/routes-manifest.json deleted file mode 100644 index 442224a..0000000 --- a/.next/dev/routes-manifest.json +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"caseSensitive":false,"basePath":"","rewrites":{"beforeFiles":[],"afterFiles":[],"fallback":[]},"redirects":[{"source":"/:path+/","destination":"/:path+","permanent":true,"internal":true,"priority":true,"regex":"^(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))\\/$"}],"headers":[],"onMatchHeaders":[]} \ No newline at end of file diff --git a/.next/dev/server/app-paths-manifest.json b/.next/dev/server/app-paths-manifest.json deleted file mode 100644 index 9e26dfe..0000000 --- a/.next/dev/server/app-paths-manifest.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/.next/dev/server/interception-route-rewrite-manifest.js b/.next/dev/server/interception-route-rewrite-manifest.js deleted file mode 100644 index 24f77ba..0000000 --- a/.next/dev/server/interception-route-rewrite-manifest.js +++ /dev/null @@ -1 +0,0 @@ -self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"; \ No newline at end of file diff --git a/.next/dev/server/middleware-build-manifest.js b/.next/dev/server/middleware-build-manifest.js deleted file mode 100644 index 4de4f4f..0000000 --- a/.next/dev/server/middleware-build-manifest.js +++ /dev/null @@ -1,15 +0,0 @@ -globalThis.__BUILD_MANIFEST = { - "pages": { - "/_app": [] - }, - "devFiles": [], - "polyfillFiles": [], - "lowPriorityFiles": [ - "static/development/_buildManifest.js", - "static/development/_ssgManifest.js", - "static/development/_clientMiddlewareManifest.js" - ], - "rootMainFiles": [], - "rootMainFilesTree": {}, - "pagesChunkGroupBootstrapParams": {} -}; diff --git a/.next/dev/server/middleware-manifest.json b/.next/dev/server/middleware-manifest.json deleted file mode 100644 index eb7130b..0000000 --- a/.next/dev/server/middleware-manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "version": 3, - "middleware": {}, - "sortedMiddleware": [], - "functions": {} -} \ No newline at end of file diff --git a/.next/dev/server/next-font-manifest.js b/.next/dev/server/next-font-manifest.js deleted file mode 100644 index dcd0697..0000000 --- a/.next/dev/server/next-font-manifest.js +++ /dev/null @@ -1 +0,0 @@ -self.__NEXT_FONT_MANIFEST="{\n \"app\": {},\n \"appUsingSizeAdjust\": false,\n \"pages\": {},\n \"pagesUsingSizeAdjust\": false\n}" \ No newline at end of file diff --git a/.next/dev/server/next-font-manifest.json b/.next/dev/server/next-font-manifest.json deleted file mode 100644 index 7b7649c..0000000 --- a/.next/dev/server/next-font-manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "app": {}, - "appUsingSizeAdjust": false, - "pages": {}, - "pagesUsingSizeAdjust": false -} \ No newline at end of file diff --git a/.next/dev/server/pages-manifest.json b/.next/dev/server/pages-manifest.json deleted file mode 100644 index 9e26dfe..0000000 --- a/.next/dev/server/pages-manifest.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/.next/dev/server/preview-props.json b/.next/dev/server/preview-props.json deleted file mode 100644 index 0969aea..0000000 --- a/.next/dev/server/preview-props.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "previewModeId": "a508c818777951bed52c0170453ef7e3", - "previewModeSigningKey": "591a19b72d1a845a81057e56fb15a2ced8409045c0ed580013f217b8c4a4ea61", - "previewModeEncryptionKey": "ded0359fbc3be263ec7ff240e7fb98fc5b836958070834f5bc954f1092afa91c" -} \ No newline at end of file diff --git a/.next/dev/server/server-reference-manifest.js b/.next/dev/server/server-reference-manifest.js deleted file mode 100644 index 272af9c..0000000 --- a/.next/dev/server/server-reference-manifest.js +++ /dev/null @@ -1 +0,0 @@ -self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"UT9PfJliySgklhhPCIM4wpEhmx9PMD9GPB4iuPIamyM=\"\n}" \ No newline at end of file diff --git a/.next/dev/server/server-reference-manifest.json b/.next/dev/server/server-reference-manifest.json deleted file mode 100644 index b5a989e..0000000 --- a/.next/dev/server/server-reference-manifest.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "node": {}, - "edge": {}, - "encryptionKey": "UT9PfJliySgklhhPCIM4wpEhmx9PMD9GPB4iuPIamyM=" -} \ No newline at end of file diff --git a/.next/dev/static/development/_buildManifest.js b/.next/dev/static/development/_buildManifest.js deleted file mode 100644 index bffda53..0000000 --- a/.next/dev/static/development/_buildManifest.js +++ /dev/null @@ -1,8 +0,0 @@ -self.__BUILD_MANIFEST = { - "__rewrites": { - "afterFiles": [], - "beforeFiles": [], - "fallback": [] - }, - "sortedPages": [] -};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB() \ No newline at end of file diff --git a/.next/dev/static/development/_clientMiddlewareManifest.js b/.next/dev/static/development/_clientMiddlewareManifest.js deleted file mode 100644 index a8acaff..0000000 --- a/.next/dev/static/development/_clientMiddlewareManifest.js +++ /dev/null @@ -1 +0,0 @@ -self.__MIDDLEWARE_MATCHERS = [];self.__MIDDLEWARE_MATCHERS_CB && self.__MIDDLEWARE_MATCHERS_CB() \ No newline at end of file diff --git a/.next/dev/static/development/_ssgManifest.js b/.next/dev/static/development/_ssgManifest.js deleted file mode 100644 index 2260768..0000000 --- a/.next/dev/static/development/_ssgManifest.js +++ /dev/null @@ -1 +0,0 @@ -self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB() \ No newline at end of file diff --git a/.next/dev/types/routes.d.ts b/.next/dev/types/routes.d.ts deleted file mode 100644 index 15617e1..0000000 --- a/.next/dev/types/routes.d.ts +++ /dev/null @@ -1,55 +0,0 @@ -// This file is generated automatically by Next.js -// Do not edit this file manually - -type AppRoutes = never -type PageRoutes = never -type LayoutRoutes = never -type RedirectRoutes = never -type RewriteRoutes = never -type Routes = AppRoutes | PageRoutes | LayoutRoutes | RedirectRoutes | RewriteRoutes - - -interface ParamMap { -} - - -export type ParamsOf = ParamMap[Route] - -interface LayoutSlotMap { -} - - -export type { AppRoutes, PageRoutes, LayoutRoutes, RedirectRoutes, RewriteRoutes, ParamMap } - -declare global { - /** - * Props for Next.js App Router page components - * @example - * ```tsx - * export default function Page(props: PageProps<'/blog/[slug]'>) { - * const { slug } = await props.params - * return
Blog post: {slug}
- * } - * ``` - */ - interface PageProps { - params: Promise - searchParams: Promise> - } - - /** - * Props for Next.js App Router layout components - * @example - * ```tsx - * export default function Layout(props: LayoutProps<'/dashboard'>) { - * return
{props.children}
- * } - * ``` - */ - type LayoutProps = { - params: Promise - children: React.ReactNode - } & { - [K in LayoutSlotMap[LayoutRoute]]: React.ReactNode - } -} diff --git a/AGENTS.md b/AGENTS.md index fe6a381..36401e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,132 +1,130 @@ -# AGENTS.md — EVABRPUploader +# AGENTS.md — Nils' MG4 -Context for AI agents and new contributors working in this repository. - -EVABRPUploader is part of **EVSuite**. The workspace `AGENTS.md` and normative -workspace `DESIGN.md` apply; this file contains only telemetry-specific additions. - -## App-specific visual direction (2.2.6) - -The user explicitly requested a permanent OLED-black theme, charcoal glass-like cards, -muted gold and cyan accents. These app resources intentionally override the shared -DESIGN.md palette, DayNight preference and flat-surface rules. Do not overwrite them -with the shared token generator. Use static gradients rather than continuous effects; -keep large touch targets, readable text and null telemetry shown as a dash. -Wide layouts start at 1000dp; narrower windows use a bottom navigation bar. +Start here. What this repository is, how its parts fit, how to build, test and ship, and the +rules for changing it. Details live in the documents linked below; `SPECS.md` is the journal +(status, to-dos, a dated log of every change) and is updated with each change. ## What this is -An Android Automotive app for the **MG4 (SAIC eh32)** that reads vehicle telemetry and -pushes it to A Better Route Planner. It runs on the car's head unit (AAOS 9, MT2712). - -Fork of Leon Kernan's `ABRP_Uploader`. See `LICENSE.md` — the licence status is -**unresolved** and that is a real constraint, not paperwork. - -## Non-negotiables - -1. **Telemetry is read-only.** No vehicle settings may be written. Window automation - was removed in 2.2.5 at the user's request, including its service and door listener. -2. **A failed read is not a zero.** Getters return `Integer`/`Float`/`Boolean` and `null` - means "could not read". Null fields are omitted from the payload. Sending 0 for SOC - tells ABRP the battery is empty and wrecks a live route plan. -3. **Fail closed.** Update origin, APK signature, unknown vehicle state — refuse rather - than assume. -4. **Never let a credential reach a URL, a log line, or a crash report.** -5. **AAOS 9 must stay stable.** A third-party app crashing the head unit is unacceptable. - Every car call goes through reflection with try/catch; keep it that way. -6. **Power matters.** The head unit is awake whenever the car is on. No wake locks, no - alarms. The GPS subscription and the upload tick both cost power — keep them in step. - -## Architecture +An app for the **MG4 (SAIC eh32)** head unit plus the backend it talks to: ``` -AbrpUploadService foreground service; scheduler thread does all IPC and HTTP -CarPropertyAdapter reflection bridge to android.car (not on the compile classpath) -TelemetryPayload pure JSON building — no Android types, unit-tested -UploadCadence when a tick actually uploads — pure, unit-tested -UploadSettings user cadence configuration -UploadLog last 20 attempts + derived service state — pure, unit-tested -SecurePrefs credentials in EncryptedSharedPreferences, migrates plaintext -AbrpApi HTTP; credentials in the POST body, never the query string -MainActivity configuration, service state, upload log +MG4 head unit (Android 9, AAOS, MT2712) + app/ "Nils' MG4" ──live data, consumption, charging──▶ server/ (Go) ──▶ InfluxDB 2 ◀── Grafana + │ │ relays app releases from the forge + └──live data, straight, with the driver's own key──▶ ABRP (A Better Route Planner) ``` -**Put logic in the pure classes.** Anything Android-free can be unit-tested on the JVM; -anything inside the service effectively cannot be tested without a car. +- **app/** — Android app (Java, minSdk 27, runs on API 28). Reads the car through the + `EVHardware/` library, shows it (Overview, Consumption, Charging), sends live data to the + server and to ABRP, and updates itself. +- **server/** — Go 1.26 API at `https://car.k8s.stinnesbeck.com`: stores telemetry, consumption + and charging in InfluxDB, pairs cars by QR code, carries settings typed on a phone, relays + app releases from the private forge. It does **not** talk to ABRP. See `server/README.md`. +- **k8s/** — plain Kubernetes manifests (InfluxDB, server, Grafana with three provisioned + dashboards, Traefik routes). See `k8s/README.md`. +- **EVHardware/** — vendored shared vehicle layer (PolyForm Noncommercial, see `LICENSE.md`). -## Source sets +Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`), work branch +`english-translation`. Upstream fork: `fatih` → github.com/fatihdonmezdev/FatihsMG4. -| Set | Contents | Ships in | -|---|---|---| -| `main` | everything shared | all builds | -| `stable` | `UpdateHook` no-op | stable channel | -| `unstable` | active GitHub pre-release updater and PackageInstaller result receiver | unstable channel | -| `debug` | `VhalProbe` — enumerates the whole VHAL | debug builds only | -| `testUnstable` | OTA policy tests | test only | +## Rules -The stable channel does not merely disable self-update — the updater is **not in the -APK**. +1. **Nothing touches the Kubernetes cluster.** Write and validate manifests only; the owner + applies them. No `kubectl` against the cluster, not even read-only, unless asked. +2. **Secrets.** Filled-in values only in `k8s/secrets/secrets.yaml` (gitignored); + `secrets.example.yaml` holds `CHANGE-ME` placeholders only. Stage files explicitly, check the + index for changes that are not yours (the owner works in parallel), and scan the staged diff + for secrets before every commit. Never print a token. +3. **Telemetry is read-only.** The app never writes a vehicle setting. +4. **A failed read is not a zero.** Unreadable values are `null`, omitted from payloads and + shown as "—". Sending 0 for SOC tells ABRP the battery is empty. +5. **Fail closed.** Updates (origin, SHA-256, package, signature), credentials, unknown state. +6. **No credential in a URL, a log line or a crash report.** Tokens go in headers or bodies. +7. **The head unit must stay stable.** Every car call is reflection with try/catch; no + blocking car reads on the main thread. +8. **Power.** No wake locks or alarms; GPS pauses while parked and idle. +9. **Honesty.** Most behaviour cannot be verified off the car. Say what was tested (unit tests, + emulator, podman) and what was not. "Not tried on the car" is an acceptable answer. -## Firmware generations +## The car (confirmed on the owner's car, firmware `SWI68-29958-1300R69`) -MG4 infotainment ships as SWI68 / SWI69 / SWI131 / SWI132 / SWI133 / SWI165, and they -differ. Property IDs confirmed on one generation are not universal. Anything vehicle-facing -must tolerate a property simply not existing. +- Display 1920×720 px at **160 dpi**; the app window is **1778×720 px** (the car's own sidebar), + about **1778×640 dp** for layouts. Pages must fit that without scrolling where possible. +- Signed with the public **AOSP test platform key**; the car build runs as `android.uid.system`. +- Steering-wheel keys arrive as the broadcast `com.saic.keyevent.hardkey.report` + (`android.intent.extra.hardkey.keycode` / `.down` / `.longpress`); the voice key is **287**. +- Not published by the car: battery, cabin and outside temperature, pack energy and capacity, + paint colour (only `ro.build.mtk2712.vehicle_type = AS28`). See `SPECS.md`, `FIRMWARE.md`. +- No third-party apps in the MG launcher: the app opens by a double press of the voice key. +- Its internet comes from the car itself (no Wi-Fi needed). -**Two read paths, split by whether the signal is firmware-agnostic:** +## The app -| Signal | Read path | Firmwares | -|---|---|---| -| Speed, outside temp, park | `EVHardware` getters (`getVehicleSpeedKmh`, `getOutsideTempCelsius`, `isVehicleInPark`) | all six — EVHardware branches per generation internally | -| SOC, range | `CarPropertyAdapter` vendor IDs (`0x2160F404`, `0x2140F41C`) | **SWI68 only** | -| Charge rate/port, cabin temp | `CarPropertyAdapter` standard-AAOS IDs | wherever the VHAL implements them | +| Tab | Contents | +|---|---| +| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info, tyre pressures; read every 0.5 s on a background thread | +| Consumption / Charging | period totals, daily log, charging sessions and curve | +| Live data | on/off, ABRP API key and user token, upload frequency | +| Server | URL, pairing by QR, ingest token, installation ID (read-only), config import | +| Settings | full screen, voice key, tyre unit, accent colour, battery, charging price, version, update check, install from USB, About | +| Logs | upload log | -`EVHardware.init()` is called in `AbrpUploadService.onCreate()` (idempotent); it detects -the generation and picks the right underlying API. Fields it covers therefore work on every -supported firmware. +Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service, +15 s tick), `TelemetryUploader` (+ `TelemetryQueue`, persisted), `TelemetryPayload` (server +limits), `AbrpApi`/`AbrpConfig`, `ServerHttp`/`ServerConfig`/`PairingClient`/ +`PhoneSettingsClient`, `ConsumptionTracker`/`ConsumptionCloudClient`, `ChargeSessionTracker`, +`RangeEstimator`, `SecurePrefs`, `ConfigImport`, `SteeringKeyListener`, `MainActivity` (UI). +Updater (car build only): `app/src/unstable/` `OtaUpdater`, `UpdateHook`, `UsbUpdate`. -**Reference firmware: `SWI68-29958-1300R69`.** The vendor EV IDs still in -`CarPropertyAdapter` are supported only on SWI68 firmware. EVHardware -has **no EV-battery abstraction for any generation**, so SOC and range cannot go through it; -until those vendor IDs are confirmed on the other generations they stay SWI68-only, and on -another firmware they may silently return nothing (handled safely — the field is omitted, but -SOC/range would be missing). Re-confirm with `VhalProbe` before trusting the vendor IDs on any -other generation. +**Put logic in pure, unit-tested classes** (no Android types); the service and activity are +hard to test without a car. -## Working here +**UI conventions:** OLED black, glass cards, an accent the user picks from the MG4 paints — +always `?attr/evAccent…` (`res/values/attrs_accent.xml`, overlays in `accents.xml`), never a +hard-coded accent hex. All UI text in `strings.xml`. 72 dp touch targets. Values right-aligned +with tabular digits. Drop-downs are Material exposed dropdown menus and dialogs go through +`showKeepingFullScreen` — a Spinner or a plain dialog brings the car's bars back. Android 9 +crashes on gradients with theme colours: use layers. -```bash -mise run check # what CI runs: permission gate + lint + unit tests -mise run build -mise run logs # adb logcat, filtered to this app +**Flavours:** `stable` (no updater at all) and `unstable` (the car build: system UID, OTA and USB +install). The car always gets `unstable`. + +## Build, test, ship + +```sh +nix develop # JDK 21, Android SDK, Go +./gradlew testStableDebugUnitTest testUnstableDebugUnitTest lintStableDebug +build-car # build/car/NilsMG4-unstable-.apk + .sha256 +publish-car # pre-release car- on the forge (needs a token) +cd server && go vet ./... && go test ./... && govulncheck ./... ``` -Gradle needs JDK 17. If a build fails in `JdkImageTransform` complaining about `jlink`, -a stale Gradle daemon started by VS Code's Java extension is being reused — -`pkill -f GradleDaemon` and retry. +- **Versions:** three-part `versionName` in `app/build.gradle`; raise `versionCode` by one with + every build that goes to the car (a lower code is refused as a downgrade). +- **Getting it to the car:** in-app update (Settings → Check for updates, via the server), or + Install from USB stick (APK + `.sha256` in the stick's top folder), or the first time through + the Bluetooth-settings file manager (`README.md`). `OTA.md` has the whole flow. +- **Server image:** `podman build` in `server/`, push to `git.stinnesbeck.com/nils/mg4-server:`, + pin the pushed digest in `k8s/server.yaml`. Validate manifests offline with + `kustomize build k8s | kubeconform` (see `k8s/README.md`). +- **Emulator:** `nix run .#car-emulator` (add `-no-window` for headless) starts an Android 9 AOSP + emulator at 1920×720, 160 dpi on port 5560 (state in `build/emulator/`). Its image shares the + car's platform key, so the car build installs as the system user. `adb shell wm size 1778x640` + gives the car's app window (taps then use those coordinates; screenshots stay 1920×720). It has + no car data (every value is "—"). While the app is open, `uiautomator dump` never sees an idle + screen; use screenshots. +- **After each change:** a `SPECS.md` done-log entry, docs that the change affects, explicit + staging, commit, push. -## Things that will bite you +## Documents -- **`android.car` is not on the compile classpath.** Everything car-related is reflection. - R8 cannot see those uses, so `proguard-rules.pro` keeps them by name. -- **Unit tests get a stub `org.json`** from `android.jar` that returns null. The real - implementation is added as a test dependency; do not remove it. -- **`service_running` in preferences goes stale-true** after a force-kill. Use - `AbrpUploadService.isRunning()`. -- **Permission changes fail CI** until allowlisted with a justification. - -## Honesty requirements - -Most of this cannot be verified off a vehicle. When reporting work, state plainly what was -tested and what was not. "Unit tests pass, not tried on the car" is the expected and -acceptable answer. Do not imply road-testing that did not happen. - - - -## This is NOT the Next.js you know - -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. - -This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. - - +| File | What | +|---|---| +| `SPECS.md` | journal: architecture, status, signals the car lacks, to-dos, dated done-log | +| `README.md` | user-facing overview, install, setup, config file | +| `OTA.md` | releases, the updater's checks, USB install | +| `FIRMWARE.md` | the head unit, firmware generations, display | +| `server/README.md`, `k8s/README.md` | API, configuration, deployment | +| `LICENSE.md` | MIT, EVHardware's PolyForm Noncommercial, the fork chain | +| `DESIGN.md`, `CHANGELOG.md` | upstream's design system and release history | diff --git a/CHANGELOG.md b/CHANGELOG.md index dcb90af..106465e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,7 @@ - Charging sessions are now metered on DC only (10 kW and above). An AC charge is an overnight affair the head unit sleeps through, so its curve, duration and energy were - guesses; the charging screen labels one as `AC şarj (ölçülmüyor)` rather than showing + guesses; the charging screen labels one as `AC şarj (ölçülmüyor)` ("AC charging (not metered)") rather than showing the previous DC session's figures next to it. ### Fixed @@ -246,7 +246,7 @@ All notable changes to this project are documented here. Format follows - OTA installs now go through the platform `PackageInstaller` instead of shelling out to `pm install -r`. The old path handed the package manager service a file inside the app's private cache, which it cannot read from its own process, and every such failure was - reported as `Kurulum reddedildi (imza uyuşmuyor?)` — a signature verdict on a correctly + reported as `Kurulum reddedildi (imza uyuşmuyor?)` ("installation refused (signature mismatch?)") — a signature verdict on a correctly signed build. A session streams the archive from this process, so no second process has to reach our cache. Approach taken from merthankaraman/DriveHub_Dort, which solved the same problem on the same head unit. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 89949b1..1d0eb65 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,6 +91,5 @@ say what the failure looked like, so the next person recognises it. ## Licensing -Read [`LICENSE.md`](LICENSE.md) first. The project's licence status is unresolved because -the upstream fork source has none. Contributions are accepted on the understanding that -they are offered under MIT once that is settled. +Read [`LICENSE.md`](LICENSE.md) first. Contributions are accepted under the MIT Licence, +except changes to `EVHardware/`, which fall under its PolyForm Noncommercial licence. diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/EVHardware.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/EVHardware.kt index f6e50c6..9ce7fb5 100644 --- a/EVHardware/lib/src/main/java/com/evsuite/hardware/EVHardware.kt +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/EVHardware.kt @@ -335,18 +335,18 @@ object EVHardware { } /** - * État d'ignition courant, en valeurs [IgnitionState] (AAOS). + * Current ignition state, as [IgnitionState] (AAOS) values. * - * CarPropertyManager d'abord. Mais SWI68 n'expose pas IGNITION_STATE via CPM : la - * property revient vide, et l'état d'allumage passait pour illisible alors que Katman5 - * le rapporte correctement — le log le montre (`Katman5: state initial = 2 (RUN/READY)`) - * pendant que le diagnostic classait IGNITION_STATE en NOT_READABLE. + * CarPropertyManager first. But SWI68 does not expose IGNITION_STATE through CPM: the + * property comes back empty, and the ignition state looked unreadable although Katman5 + * reports it correctly — the log shows it (`Katman5: state initial = 2 (RUN/READY)`) while + * the diagnostics classed IGNITION_STATE as NOT_READABLE. * - * D'où le repli sur le dernier état livré par IVehicleConditionService, converti : - * Katman5 parle en [CarIgnitionItem], cette fonction rend du [IgnitionState], et les - * deux échelles ne coïncident pas (RUN vaut 2 chez l'un, OFF vaut 2 chez l'autre). + * Hence the fallback to the last state delivered by IVehicleConditionService, converted: + * Katman5 speaks [CarIgnitionItem], this function returns [IgnitionState], and the two + * scales do not line up (RUN is 2 in one, OFF is 2 in the other). * - * Returns -1 si rien n'est lisible, 0 si la property existe mais ne dit rien. + * Returns -1 if nothing is readable, 0 if the property exists but says nothing. */ fun getCurrentIgnitionState(): Int { val v0 = getIntPropertyCPM(PROP_IGNITION_STATE, 0) @@ -356,7 +356,7 @@ object EVHardware { return vcmIgnitionAsAaos() ?: global } - /** Le dernier état Katman5 traduit en [IgnitionState], ou null s'il n'y en a pas eu. */ + /** The last Katman5 state translated into [IgnitionState], or null if there was none. */ private fun vcmIgnitionAsAaos(): Int? = when (sLastVcmIgnitionState) { CarIgnitionItem.OFF -> IgnitionState.OFF CarIgnitionItem.ACCESSORY -> IgnitionState.ACC diff --git a/FIRMWARE.md b/FIRMWARE.md index cce5ddc..ae2cea7 100644 --- a/FIRMWARE.md +++ b/FIRMWARE.md @@ -11,7 +11,12 @@ development. It must not be treated as a vendor specification. | Android | 9 (Pie) — **API 28** | | Chipset | MediaTek **MT2712** | | Device | `mt2712_saic_eh32` (eh32 = MG4) | -| Emulator display profile | **1920×1080 @ 160 dpi** | +| Display | **1920×720 px @ 160 dpi**; the app window is **1778×720 px** (≈1778×640 dp for layouts), the car's own sidebar taking the rest. Confirmed on the car 2026-10-18 (read with app 2.16.2, `SWI68`). | +| Emulator display profile | **1920×720 @ 160 dpi** | +| `ro.build.mtk2712.vehicle_type` | `AS28` (a vehicle type code; the paint colour is not among the system properties) | + +EVProfile's README gives the display as 1280×480. That is 1920×720 px at 240 dpi expressed in +dp; on this car the density is 160 dpi, so it does not apply here. ## Signing caveat @@ -42,5 +47,5 @@ follow the same path. See `AGENTS.md` for the full per-signal split. ## Emulator fidelity The `mise` emulator tasks use an API 33 automotive AVD for the car service and an API 28 -AVD at **1920×1080 @ 160 dpi** for the screen. Neither exposes the SAIC vendor properties, +AVD at **1920×720 @ 160 dpi** for the screen, as on the car. Neither exposes the SAIC vendor properties, so vehicle-specific behaviour still requires on-vehicle validation. diff --git a/LICENSE b/LICENSE index 0ab7a2a..12629f3 100644 --- a/LICENSE +++ b/LICENSE @@ -2,6 +2,8 @@ MIT License Copyright (c) 2026 Leon Kernan (original ABRP_Uploader) Copyright (c) 2026 EVABRPUploader contributors +Copyright (c) 2026 FatihsMG4 contributors +Copyright (c) 2026 Nils Stinnesbeck (Nils' MG4) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/LICENSE.md b/LICENSE.md index b97edd5..4c7c54b 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,19 +1,49 @@ # Licence -This project is released under the **MIT Licence** — see [`LICENSE`](LICENSE). +Everything in this repository is released under the **MIT Licence** — see +[`LICENSE`](LICENSE) — **except the `EVHardware/` library**, which has its own licence (below). -## Attribution +## Where it comes from -EVABRPUploader is a fork of Leon Kernan's `ABRP_Uploader`, and a meaningful part of -the original work survives in it. The copyright line names both him and this fork's -contributors, and MIT requires that notice to travel with any copy or substantial portion -of the software. Keep it. +Nils' MG4 is a fork of a chain of forks, all MIT: -The upstream repository carried no licence file of its own. Applying MIT here is the -project owner's decision; if Leon Kernan ever objects, the right response is to honour -that rather than argue about it. +- [Leon Kernan's `ABRP_Uploader`](https://github.com/lkernan/ABRP_Uploader), the original. It + carries the MIT Licence since 2026-07-29 + ([its LICENSE](https://github.com/lkernan/ABRP_Uploader/blob/master/LICENSE), commit + `75d8f2bc`). Upstream's note that it had no licence predates that. +- malys' EVABRPUploader, then + [fatihdonmezdev/FatihsMG4](https://github.com/fatihdonmezdev/FatihsMG4), both under MIT. +- This fork, under MIT as well. + +MIT requires the copyright notice to travel with any copy or substantial portion of the +software. `LICENSE` names all of them; keep every line. + +## The EVHardware library + +`EVHardware/` (the shared vehicle layer from EVSuite) is source-available under the +**PolyForm Noncommercial License 1.0.0** — see [`EVHardware/LICENSE`](EVHardware/LICENSE) and +[`EVHardware/LICENSE.md`](EVHardware/LICENSE.md). Commercial use is not permitted, and its +`Required Notice: Copyright (c) 2026 EVHardware contributors` must go with any copy. + +The car app links EVHardware into its APK, so the **built app** may only be used and passed on +non-commercially, whatever the MIT parts would allow on their own. The server, the Kubernetes +manifests and the docs do not contain EVHardware and are plain MIT. + +EVHardware's own notes say parts of it come from EVProfile, "itself a fork whose upstream +licence situation is unresolved". Checked on 2026-10-18, that is settled for the code in +question: + +- [malys/EVProfile](https://github.com/malys/EVProfile) is MIT. It is a fork of + [SliDeeN/MG4Control](https://github.com/SliDeeN/MG4Control), and its last merge from + MG4Control is a commit of 2026-08-02. +- MG4Control is MIT up to and including 2.6.5 (released 2026-08-05; its licence file dates + from 2026-06-24) and GPL v3 or later from 2.6.6 (2026-09-02). Its `LICENCE.md` states that + the earlier versions stay MIT for good. + +So what EVProfile took from MG4Control dates from MG4Control's MIT period. This covers the +lineage EVHardware names; it is not an audit of every line in EVHardware. ## No warranty -MIT disclaims all warranty and liability, and this software runs on a **vehicle** — see -[`DISCLAIMER.md`](DISCLAIMER.md) for what that means concretely. +Both licences disclaim all warranty and liability, and this software runs on a **vehicle** — +see [`DISCLAIMER.md`](DISCLAIMER.md) for what that means concretely. diff --git a/OTA.md b/OTA.md index 1e1ad16..6a0c41f 100644 --- a/OTA.md +++ b/OTA.md @@ -1,163 +1,104 @@ -# Publishing an OTA release +# Updating the car app over the air -How to ship an update the car will install by itself. Written for whoever does this -next — probably me, having forgotten the details. +How a new car build reaches the head unit without the Bluetooth-settings detour, and what to +check when it does not. -## First upgrade from 2.2.3 or older +``` +build-car ─▶ publish-car ─▶ forge pre-release (private repo, git.stinnesbeck.com/nils/mg4) + │ read with FORGE_TOKEN (read-only, server Secret) + ▼ + car server /v1/app/releases, /v1/app/assets/… + │ the car's own paired token + ▼ + app: refresh button → check → SHA-256 → install +``` -Install the platform-signed 2.2.7 APK manually once if the car still runs 2.2.3 or older. -Those builds use the old `pm install` path; publishing a newer APK does not repair the -installer already running on the car. Builds from 2.2.4 use `PackageInstaller` sessions. -Keep the unstable package ID, system shared UID and signing certificate when upgrading. +The repository stays private and the forge token stays on the server: the car asks its own +server, with its own token, and never holds a forge credential. The server only relays +bytes. Every check that decides whether code gets installed is in the app. -The 2.2.5 build removes window automation. It also distinguishes failed release checks -from a successful check with no newer release. On 2026-09-29, GitHub still published only -2.2.3 at the last live check. The local 2.2.7 build includes these fixes and the redesigned -UI; it becomes available to OTA only after publication as a pre-release asset named -`FatihsMG4-unstable-2.2.7.apk`, together with -`FatihsMG4-unstable-2.2.7.apk.sha256`. +## Publishing a release -The vehicle artifact uses `com.evsuite.abrp.unstable`, `android.uid.system`, and the -DriveHub_Kamera platform key. Its certificate SHA-256 must be -`c8a2e9bccf597c2fb6dc66bee293fc13f2fc47ec77bc6b2b0d52c11f51192ab8`. -The stable build used for a Samsung UI preview is a separate package without OTA or the -vehicle's shared UID; do not use that preview APK for the MG4 upgrade. +1. **Raise the version** in `app/build.gradle` (`versionCode` must increase; `versionName` + is three-part, e.g. `2.16.0`). The car build reports it as `2.16.0-unstable`. +2. **Build:** `nix develop`, then `build-car`. It signs with the AOSP platform key the head + unit trusts, checks the signer, and writes `build/car/NilsMG4-unstable-.apk` and + its `.sha256`. +3. **Publish:** `publish-car`. It creates the pre-release `car-` on the current + commit and attaches the APK and the `.sha256`. It needs a forge token that may write the + repository's releases, in `~/.config/nilsmg4/forge-token` (or `$FORGE_TOKEN`); the token is + only sent in a header, never printed. It refuses a version that is already published. -## What the updater actually looks for +By hand, the same: in the forge's web UI create a release with tag `car-`, tick +**pre-release**, and attach both files with exactly their names. -`OtaUpdater.check()` reads `https://api.github.com/repos/fatihdonmezdev/FatihsMG4/releases` -and walks **every** entry, keeping the highest version rather than the first match. A -release is a candidate only if all of these hold: +## What the updater accepts -| Requirement | Where it is enforced | What happens otherwise | +The app asks the car server for the releases (`GET /v1/app/releases`) and walks every one, +keeping the highest version: + +| Requirement | Where it is enforced | Otherwise | |---|---|---| -| Marked **pre-release** | `release.optBoolean("prerelease")` | Skipped entirely | -| Asset name contains `unstable` | `name.contains("unstable")` | Asset ignored | -| Asset name ends `-.apk` | `ASSET_VERSION` regex | No version, asset ignored | -| Version strictly higher than installed | `isNewer()` | Nothing to do, reports "Güncel" | -| Download host on the allowlist | `isAllowedUrl()` | Refused and logged | -| APK signed with the **same certificate** as the running app | Android `PackageInstaller` | Installation is rejected by the platform | -| Matching `.sha256` asset or `SHA256SUMS` entry | `fetchExpectedSha256()` | Update ignored/refused | +| Marked **pre-release**, not a draft | `selectUpdate` | Skipped | +| Asset name contains `unstable` and ends `-.apk` | `ASSET_VERSION` regex | Ignored | +| Version strictly higher than installed | `isNewer()` | "Up to date" | +| Every URL https and **exactly** the configured server's host | `isAllowedUrl()` | Refused | +| A matching `.sha256` (or `SHA256SUMS`) asset, and the APK's bytes match it | `fetchExpectedSha256()`, `download()` | Refused, file deleted | +| The archive is this app's package | `archiveIsThisPackage()` | Refused | +| Signed with the **same certificate** as the installed app | Android's installer | Not installed | -The last one is the one that bites. The car runs a platform-signed build; an APK signed -with a debug key — or anyone else's key — is downloaded, rejected and deleted. Sign every -release with the same platform key or the update silently never lands. +The version comes from the **asset file name**, not the tag. -The version comes from the **asset filename**, not the git tag. The tag can stay -`unstable` forever and be moved; the filename is what identifies a build. +## Installing -## Release checklist +Press the refresh button in the app's top bar. The app checks, downloads the APK into +**Downloads** (with progress), verifies its SHA-256, and hands it to a `PackageInstaller` +session. As the system user it may install without asking; if the platform wants a +confirmation, the confirmation screen opens. When a session cannot start at all, the file +manager opens in Downloads (or, where the head unit has none, open the APK there with the +Files app). The verified APK stays in Downloads either way. -1. **Raise the version.** `app/build.gradle`, `defaultConfig`: +At start the app only checks, and shows "Update available" when there is one. It never +downloads or installs on its own, and the upload service never checks: installing restarts +the app, which must not happen mid-drive on its own. - ```gradle - versionCode 24 // must increase — Android orders updates by this, not by name - versionName "2.2.7" - ``` +## Installing from a USB stick - The unstable flavour appends its own suffix, so `2.2.7` becomes `2.2.7.0-unstable`. - Check what the installed build reports before picking a number: the app's About - dialog shows it, and `isNewer` compares numerically segment by segment. +For development, without the forge: copy `NilsMG4-unstable-.apk` **and** its `.sha256` +from `build/car/` to the top folder of a USB stick, plug it in, and press **Install from USB +stick** on the Settings tab. The app takes the newest such file that is newer than the installed +version, checks it against the `.sha256`, checks the package, and installs it with the same +installer session as an over-the-air update. The car build only; the stable build has no button. -2. **Build.** - - ```bash - ./gradlew :app:assembleUnstableDebug - ``` - -3. **Sign with the platform key.** Options come *before* the input APK — apksigner - rejects the other order. - - ```bash - BT=~/Android/Sdk/build-tools/35.0.0 - # Local copies from DriveHub_Kamera/tools; private and gitignored. - KEYS="$PWD/tools" - - "$BT/zipalign" -f 4 \ - app/build/outputs/apk/unstable/debug/app-unstable-debug.apk aligned.apk - - "$BT/apksigner" sign \ - --key "$KEYS/platform.pk8" \ - --cert "$KEYS/platform.x509.pem" \ - --out FatihsMG4-unstable-2.2.7.apk \ - aligned.apk - - rm aligned.apk - ``` - - The output filename **is** the version the updater reads. Name it - `FatihsMG4-unstable-.apk`, then create its required integrity sidecar: - - ```bash - shasum -a 256 FatihsMG4-unstable-2.2.7.apk \ - > FatihsMG4-unstable-2.2.7.apk.sha256 - ``` - -4. **Verify the signer** before uploading. It must say `CN=Android`: - - ```bash - "$BT/apksigner" verify --print-certs FatihsMG4-unstable-2.2.7.apk - ``` - -5. **Publish.** On github.com/fatihdonmezdev/FatihsMG4 → Releases → Draft a new release: - - tag: `unstable` (reuse it; the rolling tag is the design) - - **tick "Set as a pre-release"** ← the whole thing is inert without this - - attach the signed APK and its `.sha256` sidecar - -6. **Install it.** Open the app on the head unit and press the refresh button in the top - bar. The dialog shows download progress, verifies SHA-256, then reports that the - PackageInstaller session has started. +On the Android 9 emulator (car build, system user) this installed without asking, from the +storage's top folder; on the car the stick's top folder is used the same way. ## When it says something else -| Toast | Meaning | +| Message | Meaning | |---|---| -| `Güncel (2.2.2.0-unstable)` | No release beat the installed version. Check the pre-release tick and the asset filename. | -| `Güncelleme reddedildi: SHA-256 doğrulaması yok` | APK has no matching sidecar, or the sidecar is invalid/unreachable. | -| `İndirme veya SHA-256 doğrulaması başarısız` | Download failed or its bytes do not match the published hash. | -| `Kuruluyor: 2.2.3 — ekrandaki onayı bekleyin` | The session was committed. Not done yet: watch for the platform's own toast, or a confirmation dialog to accept. | -| `Kurulum reddedildi: imza uyuşmuyor` | Genuinely the wrong key — rebuild and re-sign with `platform.pk8`. Unlike the old catch-all, this one means what it says. | -| `Kurulum reddedildi: APK okunamadı` | The archive does not parse, or it is built for another package. | -| `Kurulum başlatılamadı … Log sayfasına bakın` | The session could not be created or committed. `adb logcat -s EVABRP.Update`. | -| `Kurulum başarısız: INSTALL_FAILED_…` | From `OtaInstallResultReceiver` — the package manager's own words for why it refused. | -| `Güncelleme kontrolü başarısız` | The check threw. `adb logcat -s EVABRP.Update OtaUpdater` if a cable is available; otherwise the in-app Log page. | +| `Up to date (2.16.0-unstable)` | No published pre-release is newer. Check the pre-release tick and the asset name. | +| `Set up the server first (Server tab): updates come through it` | The app has no server URL and token. | +| `Update check failed` | The server could not be reached, or answered with an error: 404 when updates are off on the server (no `FORGE_TOKEN`), 502 when the forge refused the server's token. | +| `Update refused: no SHA-256 verification` | The release has no matching `.sha256`, or it is unreadable. | +| `Download or SHA-256 verification failed (…)` | The download broke off, or the bytes do not match the published hash. | +| `Update refused: the downloaded file is not this app` | The archive does not parse, or it is another package. | +| `Installing 2.16.0… the app restarts when it is done` | The session was committed; the result follows as its own toast. | +| `Installation failed: INSTALL_FAILED_… The APK is in Downloads.` | The package manager's own words. A signature error means the APK was not signed by `build-car`. | +| `… is in Downloads — tap the file to install` | A session could not start; install from Downloads by hand. | -## How the install works, and why it is not `pm install` +On the car's Logs tab, or with a cable: `adb logcat -s EVABRP.Update OtaUpdater`. -`OtaUpdater.install()` used to shell out to `/system/bin/pm install -r`. On this head unit -that fails every time, and the reason is worth writing down because the symptom pointed -somewhere else entirely. +## Why a PackageInstaller session, not `pm install` -The downloaded APK lives in `context.getCacheDir()`. `pm` does not install the file itself -— it passes the path to the package manager service, which opens it from *its* process and -SELinux context, where an app's private cache is not readable. The install failed, and -because `install()` returned a bare boolean, the caller reported the only failure it knew -how to name: `Kurulum reddedildi (imza uyuşmuyor?)`. A correctly signed build, refused with -a signature error it had not earned. The `pm` output that would have said so was captured -and thrown away. - -It now uses the platform `PackageInstaller`: we open the archive ourselves and stream it -into a session, so no second process ever has to read our cache. The approach is taken from +Upstream first shelled out to `/system/bin/pm install -r` with the APK in the app's private +cache. That fails on this head unit: `pm` hands the path to the package manager service, +which opens it from its own process and SELinux context, where an app's private cache is not +readable — and the failure surfaced as a signature error it never was. A session takes no +path: the app opens the archive itself and streams it in. The approach comes from [merthankaraman/DriveHub_Dort](https://github.com/merthankaraman/DriveHub_Dort), which hit -the same wall on the same hardware. +the same wall on the same hardware. Upstream later fell back to "download to Downloads and +tap it"; this fork tries the session first and keeps that as the fallback. -Two consequences for anyone reading a failure: - -- **Committing a session is not installing.** `commit()` is asynchronous. The refresh button - can only report that an install *started*; the verdict arrives at - `OtaInstallResultReceiver` and appears as its own toast. -- **`STATUS_PENDING_USER_ACTION` is not an error.** The platform is asking the driver to - confirm. The receiver launches the confirmation activity the platform hands it. - -Still unverified on the car: whether this build installs silently or raises that -confirmation. Either way it installs, which the old path did not. - -## Why installation is manual - -`UpdateHook.checkInBackground(this)` performs an availability-only check at app start and -shows a notification toast when a complete APK + hash pair exists. It never downloads or -installs. The refresh button is the only trigger for download, verification and installation. - -The upload service does **not** check for updates on its tick. Installing a new APK -restarts the process, and doing that mid-drive would interrupt the telemetry the service -exists to send. +**Not yet tried on this car:** whether the session installs silently as the system user, or +asks for a confirmation. diff --git a/README.md b/README.md index d723b58..2c122bd 100644 --- a/README.md +++ b/README.md @@ -1,261 +1,237 @@ -# FatihsMG4 +# Nils' MG4 -

FatihsMG4

+An Android Automotive app for the **MG4 (SAIC eh32)** head unit, plus a small server: the car +sends its telemetry to my own server at car.k8s.stinnesbeck.com, which stores it in InfluxDB 2 +for the Grafana dashboards, and its live data straight to +[A Better Route Planner](https://abetterrouteplanner.com). -[![Release](https://img.shields.io/github/v/release/fatihdonmezdev/FatihsMG4?include_prereleases&sort=semver)](https://github.com/fatihdonmezdev/FatihsMG4/releases) -[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +No OBD dongle and no phone in the loop: the app runs on the head unit and reads the car's own +APIs. -Sends live telemetry from an **MG4 (SAIC eh32)** to -[A Better Route Planner](https://abetterrouteplanner.com) — battery level, speed, range, -temperature, charging state and position — straight from the car's own APIs. - -No OBD dongle. No Home Assistant. No phone in the loop. The app runs on the car's head -unit and talks to ABRP directly. +- Repository: https://git.stinnesbeck.com/nils/mg4 +- Fork of [fatihdonmezdev/FatihsMG4](https://github.com/fatihdonmezdev/FatihsMG4), itself built on malys' EVABRPUploader and Leon Kernan's `ABRP_Uploader`. +- What changed and what is still to do: [`SPECS.md`](SPECS.md). > ⚠️ **No warranty, no liability.** This software is provided "as is" and runs on a -> **vehicle**. Installing it is your decision and your risk — see +> **vehicle**. Installing it is your decision and your risk; see > [`DISCLAIMER.md`](DISCLAIMER.md). Not affiliated with SAIC, MG Motor or Iternio. -> MG and MG4 are third-party marks used only to identify compatibility; no official origin -> or approval is claimed. +> MG and MG4 are third-party marks used only to identify compatibility. -> **Fork notice.** This is a fork of Leon Kernan's `ABRP_Uploader`, substantially -> reworked. **Read [`LICENSE.md`](LICENSE.md) before redistributing anything** — the -> upstream project carries no licence, which limits what may legally be published. +> **Licence.** MIT, like Leon Kernan's original `ABRP_Uploader` and the forks in between — +> except the bundled `EVHardware/` library, which is PolyForm Noncommercial, so the built app +> may only be used and shared non-commercially. See [`LICENSE.md`](LICENSE.md). --- ## Contents -- [Screenshots](#screenshots) -- [Overview](#overview) -- [Install](#install) -- [Configuration](#configuration) +- [Screenshot](#screenshot) +- [How it fits together](#how-it-fits-together) +- [The app](#the-app) +- [Install on the car](#install-on-the-car) +- [Setup](#setup) - [Building](#building) +- [Server and dashboards](#server-and-dashboards) - [Project layout](#project-layout) - [Project documents](#project-documents) -- [Security](#security) -- [Contributing](#contributing) -- [Legal](#legal) - [Credits](#credits) -## Screenshots +## Screenshot -Version 2.2.6 introduces an OLED-black interface with charcoal cards, muted gold and -cyan accents, a vehicle overview, and responsive side or bottom navigation. This -app-specific theme intentionally overrides the shared EVSuite visual tokens. -Tablet UI previews at 1280×720; vehicle readings are unavailable on this device. +Version 2.8.2 at the head unit's 1920x720 pixels and 160 dpi, in full screen (Android 9 emulator; no +vehicle there, so every value shows as a dash).

- OLED vehicle overview with energy and tyre panels above compact metrics - Dark ABRP account configuration + Overview tab: battery, range, range estimates, power and speed tiles above the energy and tyre-pressure cards

---- +## How it fits together -## Overview -| Signal | Source | Firmwares | -|---|---|---| -| State of charge | `EVHardware` vendor charging service, with a SWI68 property fallback | all supported (fallback SWI68 only) | -| Range | `EVHardware` vendor charging service, standard AAOS property, then SWI68 fallback | all supported | -| Speed, parked state | `EVHardware` shared energy snapshot | all supported | -| Outside temperature | The head unit's weather broadcast, falling back to its map-service query; the vehicle sensor first where one exists | all supported (the vehicle sensor is absent on MG4) | -| Charging, DC fast charging | Charge port state + charge rate heuristic | where the VHAL implements them | -| Charged energy this session | `EVHardware` charging-session integration | where the charge rate is readable | -| State of energy, pack capacity, pack temperature | `EVHardware` typed standard-AAOS reads | where the VHAL implements them | -| Odometer, tyre pressures | Standard AAOS properties (privileged) | platform-signed installs only | -| Cabin temperature | `EVHardware` typed HVAC read | where the VHAL implements it | -| Climate setpoint | `EVHardware` shared energy snapshot | all supported | -| Position, elevation, heading | GPS | — | +``` +car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go) + │ ──daily chunks─────▶ │ + │ ──charging sessions▶ ▼ + │ InfluxDB 2 ◀── Grafana + └──live telemetry─────▶ ABRP +``` -All vehicle telemetry goes through **EVHardware**, which detects the generation -(SWI68/69/131/132/133/165), owns property ids and units, and applies the shared nullable -fallback order. `CarPropertyAdapter` remains only as transport for the debug-only VHAL probe -and is never instantiated by the production service. See [`FIRMWARE.md`](FIRMWARE.md). +The car sends live data to ABRP itself, with the ABRP credentials set on the Live data tab, so +ABRP and the server do not depend on each other. The server only stores. The car authenticates +with the server using a token of its own, obtained by pairing with a phone (below). -Odometer and tyre pressures sit behind privileged AAOS permissions: a platform-signed -install sends them, any other install simply omits them. +## The app -A property the car will not give up is **omitted** from the payload, never sent as zero — -so ABRP is never told the battery is empty because a read failed. Position is held to the -same rule in time as well as in value: a GPS fix older than `max(5 min, 3 x your upload -interval)` is dropped rather than resent, so a stale fix cannot report the car back at the -place it set off from. +| Tab | What it shows | +|---|---| +| **Overview** | Battery, range, range since start and at the 5-minute average with their usage, energy since start, power and speed with their 5-minute averages; odometer, climate setpoint and tyre pressures | +| **Consumption** | Daily and period consumption, efficiency | +| **Charging** | Charging sessions and curves, cost in euro | +| **Live data** | Live data on/off, ABRP credentials for sending directly from the car, upload frequency | +| **Server** | Server URL, pairing by QR code, ingest token, installation ID, config import | +| **Settings** | Full screen, voice key, tyre pressure unit, accent colour, battery (usable capacity, manual SoH), charging price, version, update check, About | +| **Logs** | Upload log | -Telemetry only reads the car. Version 2.2.5 removes window automation entirely; there -is no window service, door listener or window command path in the app. +- **Opening the app:** the MG4 launcher does not list third-party apps. **Double-press the + voice key** on the steering wheel and the app comes to the front (switch on the Settings + tab, on by default). +- **Full screen:** hides SAIC's top and bottom bars while the app is open; a swipe from the + edge shows them briefly (Settings tab, on by default). +- **Live telemetry:** every 15 s while driving or charging, much less often while parked. A + value the car cannot read is left out, never sent as zero, so ABRP is never told the battery + is empty because a read failed. +- **Read-only:** the app never writes a vehicle setting. -## Install -The MG4 head unit hides Settings and APK install. The known route in: -The MG4 head unit has no visible way to open Settings or install an APK. The known route -in (via the on-screen keyboard) is: +Signals the MG4 does not publish (battery, cabin and outside temperature, pack energy and +capacity) are listed in [`SPECS.md`](SPECS.md#signals-the-car-does-not-provide). -1. Open any app with a text field and tap it to raise the on-screen keyboard — e.g. the - Amazon Music app's email/login field. -2. **Long-press** the comma `,` (or the `@`) key on the keyboard. -3. Tap **"Language settings"**. -4. Tap the **search** icon in the top bar and type **`backup`**. It opens an empty page — - now press the **back** arrow, and you land in Android's Settings panel. -5. Enable **Developer options**, and turn on **"Install unknown apps"** (unknown sources). -6. In Settings, search **`storage`** — you now have access to internal storage and the USB - key. Navigate to the APK and tap it to install. +## Install on the car -> ⚠️ You are enabling developer options and sideloading on a car. Do this **parked**, and -> only with an APK you trust. See [DISCLAIMER.md](DISCLAIMER.md). +The car build (`NilsMG4-unstable-.apk`) runs with the system UID and is signed with the +AOSP test platform key that the MG4 head unit uses. It installs next to the old FatihsMG4 app +(package `com.stinnesbeck.nilsmg4.unstable`), not over it. -Two channels. Pick one — they install side by side. +Without adb, through the head unit's hidden file manager: -| Channel | Auto-update | Use it if | -|---|---|---| -| **Stable** | No. Contains no updater at all. | You want the car to run what you put on it | -| **Unstable** | Yes, from GitHub pre-releases | You are testing and want fixes as they land | +1. Copy the APK to a USB stick and plug it in. +2. Open **Bluetooth Settings** and select the car name. +3. Long-press the **comma** key until the keyboard settings appear. +4. **Android Keyboard Settings → Languages**, then search for `file` in the box at the top. +5. That opens the Files app: open the USB stick, tap the APK and install. -Grab the APK from [Releases](https://github.com/fatihdonmezdev/FatihsMG4/releases). Stable builds are the tagged ones; unstable builds -are marked pre-release. +With adb: -### Getting the APK onto the car +```sh +adb push build/car/NilsMG4-unstable-.apk /data/local/tmp/update.apk +adb shell "pm install -r -f -d -t /data/local/tmp/update.apk" +``` -The MG4 has no visible file manager. To reach it: +> ⚠️ Do this **parked**, and only with an APK you trust. See [DISCLAIMER.md](DISCLAIMER.md). -1. Open **Bluetooth Settings** and select the car name. -2. Long-press the **comma** key until the keyboard settings appear. -3. **Android Keyboard Settings → Languages**. -4. Search for `file` in the search box at the top. -5. That gives you the Files app. +After that first install, the app updates itself: publish a build with `publish-car`, then +press the refresh button in the app's top bar (`OTA.md`). The car fetches it through your car +server, so the repository stays private. -From there, open your USB stick and tap the APK. +## Setup -### First run +1. Open the app (voice key double press, or "Open" right after installing). +2. On the **Server** tab the URL is already `https://car.k8s.stinnesbeck.com`. +3. Tap **Pair with phone**. Scan the QR code with a phone (or open the address shown and enter + the code), and approve with the server's approval password. The car receives its token. +4. On the **Live data** tab, turn live data on. The service starts with the car from then on. +5. Enter your ABRP API key and user token on the same tab, so the car sends live data to ABRP. -1. Open the app. -2. By default, the app prefills the open-source telemetry API key published by the - [SAIC Python MQTT Gateway](https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway#abrp-api-integration). - You can replace it with your own key. Get a token from your ABRP account. -3. Paste the token, press **Test** (read-only — it does not send telemetry), then **Save**. -4. Turn the switch on. The service restarts with the car from then on. +### On your phone -Typing a long API key and token on the car's on-screen keyboard is painful. Instead you can -put them in a text file and tap **Import file** — see [Config file](#config-file) below. - -## Configuration - -| Setting | Default | Notes | -|---|---|---| -| Upload frequency | 60 s | 15 / 30 / 60 / 120 / 300 s. Applies while driving | -| More often at low battery | On | Tightens to 15 s below the threshold | -| Low battery threshold | 20 % | Editable, 1–99 | -| Service on/off | Off | Also the "stop uploading" control | - -Parked and unplugged, the app drops to **one upload every 15 minutes** on its own, -whatever the setting. A state change — parked, plugged in, unplugged — always uploads -immediately. +Once the car is paired, **Enter on phone** (Live data tab, and Settings → Charging price) shows a +QR code. It opens a form on your server where you type the ABRP API key and user token, the +charging price and the usable capacity, and send them with the approval password. The car +collects them within a few seconds and saves them through the same checks as the fields on +screen. The server only passes them on: they are held in memory until the car has them (at most +ten minutes), never stored or logged. ### Config file -To avoid typing on the head unit, put your settings in a plain text file and tap **Import file** -on the **ABRP** tab. The app fills in whatever the file contains. Nothing is sent — press -**Test**, then **Save** as usual. - -The head unit has no file picker, so the file is not chosen by hand: copy it from a PC into +Instead of typing, put the settings in a text file and tap **Import file** on the Server tab. +The head unit has no file picker, so copy the file to ``` -Android/data/com.evsuite.abrp/files/ +Android/data/com.stinnesbeck.nilsmg4.unstable/files/ ``` -on the USB stick (or on the head unit's internal storage), then tap **Import file**. The app -scans that folder on every mounted volume and takes the first file that parses — the name does -not matter. If nothing is found it shows the exact path to copy to. On a phone or tablet, where -a document picker exists, the button falls back to letting you pick any file. - -The file is a `key = value` list, one per line. Blank lines and lines starting with `#` are -ignored, keys are case-insensitive, and any key you leave out keeps its current value: +on the USB stick or the internal storage. The app scans that folder on every mounted volume and +takes the first file that parses. Format, one `key = value` per line, `#` for comments; keys left +out keep their current value: ``` -# ABRP Uploader config -api_key = your-abrp-api-key -token = your-abrp-user-token - -# optional — the cadence controls, same values as the UI -interval_sec = 60 # snapped to 15 / 30 / 60 / 120 / 300 +server_url = https://car.k8s.stinnesbeck.com +token = the server's INGEST_TOKEN (not needed after pairing) +installation_id = only to carry over history from an install older than 2.9.0 +interval_sec = 60 # 15 / 30 / 60 / 120 / 300 boost_low_soc = true -low_soc_percent = 20 # clamped to 1–99 +low_soc_percent = 20 +abrp_api_key = your ABRP API key +abrp_token = your ABRP user token +charging_price = 0.39 # €/kWh +usable_kwh = 50.8 # usable battery capacity, for the range estimates ``` -Only `api_key` and `token` are usually needed. The file is read once on import and not kept — -your credentials live only in the app's encrypted store afterwards. +After a successful import the app deletes the file, since it holds the token; credentials live +only in the app's encrypted store. + +The installation ID is derived from the head unit, so a reinstall finds its history again on its +own. `installation_id` is only needed to carry history over from an install made before 2.9.0. +A paired token belongs to one installation ID: after changing the ID, pair again. ### Power -The app holds **no wake lock** and schedules **no alarms**, so it cannot wake a sleeping -head unit; it costs nothing while the car is off. While the car is on, both the upload tick -and the GPS subscription cost power, and the GPS interval follows your configured -frequency. - -At defaults that is roughly **59 uploads/hour driving** and **3/hour parked**. +The app holds **no wake lock** and schedules **no alarms**, so it cannot wake a sleeping head +unit and costs nothing while the car is off. ## Building -Requires JDK 17 and the Android SDK. With [mise](https://mise.jdev): -```bash -mise install # JDK -mise run bootstrap # Android SDK + local.properties -mise run check # permission gate + lint + tests -mise run build # debug APK +With Nix: + +```sh +nix develop # JDK 21, Android SDK, Go +./gradlew assembleStableDebug # debug APK for a phone or tablet +build-car # car APK, signed with the AOSP test platform key ``` -Without mise: set `JAVA_HOME` to a JDK 17, write `sdk.dir` into `local.properties`, then -`./gradlew assembleStableDebug`. +`build-car` writes `build/car/NilsMG4-unstable-.apk` and a `.sha256` next to it, and +checks the signing certificate before it finishes. -Release builds are signed from environment variables. These sign the **APK** and have -nothing to do with your ABRP API key, which you type into the app itself: +Tests and lint: -| Variable | What it is | -|---|---| -| `SIGNING_KEYSTORE` | Path to the keystore file (CI decodes `SIGNING_KEYSTORE_BASE64` into one) | -| `SIGNING_STORE_PASSWORD` | Opens the keystore container | -| `SIGNING_KEY_ALIAS` | Which key inside it to sign with (default `platform`) | -| `SIGNING_KEY_PASSWORD` | Opens that particular key — a keystore protects each key separately | +```sh +./gradlew testStableDebugUnitTest testUnstableDebugUnitTest lintStableDebug +``` -**Never commit a keystore or a password.** Put them in `mise.local.toml`, which is -gitignored, or in GitHub Actions secrets. +[`mise`](https://mise.jdx.dev) tasks from upstream (`mise run check`, `mise run build`) still +work for the plain Gradle builds. + +## Server and dashboards + +- **Server** ([`server/`](server/README.md)): Go, API only. Live telemetry, daily consumption + chunks, charging sessions, pairing. Image at + `git.stinnesbeck.com/nils/mg4-server`. +- **Kubernetes** ([`k8s/`](k8s/README.md)): plain manifests for the server, InfluxDB 2 and + Grafana, applied by hand with `kubectl apply -k k8s/`. Secrets go in the gitignored + `k8s/secrets/secrets.yaml`, from the template next to it. +- **Grafana dashboards** (`k8s/grafana/dashboards/`): Live, Consumption and Charging, provisioned + with the InfluxDB datasource. ## Project layout + ``` -app/src/main upload service, ABRP payload and UI -EVHardware shared vehicle reads, energy snapshot and integration -app/src/stable no-op update hook — the stable channel cannot self-update -app/src/unstable audited updater policy; runtime trigger currently suspended -app/src/debug VHAL probe tools, absent from every release build +app/src/main app: upload service, server client, pairing, UI +app/src/stable no-op update hook (no updater in the APK) +app/src/unstable car build: system UID, updater (currently pointed nowhere) +app/src/debug VHAL probe tools, absent from release builds app/src/test JVM unit tests +EVHardware shared vehicle reads per firmware generation +server/ Go server +k8s/ Kubernetes manifests and Grafana provisioning +flake.nix dev shell and build-car ``` ## Project documents + | Document | What it covers | |---|---| -| [DESIGN.md](DESIGN.md) | The EVSuite design system — colour, type, touch targets, icons | +| [SPECS.md](SPECS.md) | Journal of this fork: architecture, status, to-dos, done log | +| [FIRMWARE.md](FIRMWARE.md) | MG4 firmware generations and how the app reads each | | [AGENTS.md](AGENTS.md) | Context for AI agents working in this repository | -| [CONTRIBUTING.md](CONTRIBUTING.md) | How to build, test and submit a change | -| [SECURITY.md](SECURITY.md) | Threat model and vulnerability disclosure | -| [DISCLAIMER.md](DISCLAIMER.md) | Vehicle-safety disclaimer — read before installing | -| [CHANGELOG.md](CHANGELOG.md) | Release history | -| [LICENSE.md](LICENSE.md) | Licence text | - -## Security -See [SECURITY.md](SECURITY.md) for the threat model and how to report a vulnerability -privately. - -## Contributing -See [`CONTRIBUTING.md`](CONTRIBUTING.md). Short version: this code runs on a moving -vehicle, so changes need tests and a clear account of what you verified on a car and what -you did not. - -## Legal -- [`DISCLAIMER.md`](DISCLAIMER.md) — no warranty, no liability, not affiliated with the - carmaker or with ABRP. -- [`LICENSE.md`](LICENSE.md) — unresolved licence status inherited from the fork source. - Read it before redistributing. -- [`SECURITY.md`](SECURITY.md) — how to report a vulnerability privately. +| [DESIGN.md](DESIGN.md) | The EVSuite design system (the app overrides its palette) | +| [SECURITY.md](SECURITY.md) | Threat model | +| [DISCLAIMER.md](DISCLAIMER.md) | Vehicle-safety disclaimer; read before installing | +| [CHANGELOG.md](CHANGELOG.md) | Upstream release history | +| [LICENSE.md](LICENSE.md) | Licences: MIT, and PolyForm Noncommercial for `EVHardware/` | ## Credits -- Original app: **Leon Kernan** — the car-API approach and the first working uploader. + +- **fatihdonmezdev**: [FatihsMG4](https://github.com/fatihdonmezdev/FatihsMG4), the app this fork + starts from. +- **malys**: EVABRPUploader. +- **Leon Kernan**: the original `ABRP_Uploader`, the car-API approach and the first working + uploader. - ABRP telemetry API: [Iternio](https://documenter.getpostman.com/view/7396339/SWTK5a8w). -- Sibling project: **EVProfile**, whose security and CI patterns this repo reuses. diff --git a/SPECS.md b/SPECS.md new file mode 100644 index 0000000..aa47a41 --- /dev/null +++ b/SPECS.md @@ -0,0 +1,298 @@ +# SPECS — Nils' MG4 + +Journal for this fork of [fatihdonmezdev/FatihsMG4](https://github.com/fatihdonmezdev/FatihsMG4): +what has been changed, and what is still to do. + +- Our repository: https://git.stinnesbeck.com/nils/mg4 +- Upstream: https://github.com/fatihdonmezdev/FatihsMG4 (itself a fork of malys / EVABRPUploader + and Leon Kernan / ABRP_Uploader). Credited in the app's About dialog. + +## Architecture: car server at car.k8s.stinnesbeck.com + +The car sends everything to our own server, which stores it in InfluxDB 2, and its live data +straight to ABRP with the ABRP credentials kept (encrypted) in the app. The server does not talk +to ABRP. Grafana reads InfluxDB directly for the dashboards. + +``` +car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go) + │ ──daily chunks─────▶ │ + │ ──charging sessions▶ ▼ + │ InfluxDB 2 ◀── Grafana (car-grafana.k8s.stinnesbeck.com) + └──live telemetry─────▶ ABRP api.iternio.com/1/tlm/send +``` + +- **Server:** `server/`, Go 1.26, API only — see `server/README.md` for endpoints, configuration + and the storage layout. Replaced the Next.js app from upstream (`web/`, removed 2026-10-17). +- **Dashboards:** Grafana, provisioned from `k8s/grafana/`, reading with a read-only token: Live + (stats, SoC/range, power/speed, route map, temperatures, tyres), Consumption (period totals, lifetime, SoH, per-day energy, + distance and efficiency) and Charging (totals, session table, curves). Prices in euro. +- **Pairing:** the car shows a QR code, a phone approves it with the approval password, and the car + gets its own token, bound to its installation id (RFC 8628-style; `server/README.md`). The + shared `INGEST_TOKEN` still works. The same QR flow carries settings typed on the phone. +- **Car app:** tabs Overview, Consumption, Charging, Live data (on/off, ABRP credentials, + frequency), Server (URL, pairing, token, installation id, config import), Settings (display, + voice key, tyre unit, accent colour, battery, charging price, Wi-Fi), Logs. Live telemetry every + 15 s while driving or charging, to the server and straight to ABRP; consumption chunks and + charging sessions to the server. +- **Deployment:** `k8s/`, plain manifests. **Nothing is ever applied to the cluster by us** — + Nils applies them (`k8s/README.md`). TLS comes from Traefik's default certificate. + +### Status + +Current: app 2.14.2, server 2.5.0 (pinned by digest in `k8s/server.yaml`). + +- [x] On the car: QR pairing, values on the Overview, voice-key double press, full screen + without black bands. +- [x] Go server: tests and govulncheck clean; end to end in podman against InfluxDB 2.7.12 + (telemetry, consumption, charging, pairing, settings from the phone) and Grafana 13.2.3 (all + dashboard queries return data). +- [x] Manifests validated offline (kubeconform, with Traefik CRDs). **Applied by Nils only.** +- [ ] Check Grafana's host in `k8s/ingress.yaml` (placeholder `car-grafana.k8s.stinnesbeck.com`). +- [x] Screen on the car: 1920×720 px at 160 dpi, app window 1778×720 px (Logs → SCREEN). +- [x] Car-colour probe on the car: only `ro.build.mtk2712.vehicle_type = AS28`; the paint colour + is not in the system properties. It may be in the VHAL (a debug-build VhalProbe listing + would tell); until then the accent is chosen by hand. +- [ ] Not yet tried on the car: live data straight to ABRP (2.11), Enter on phone (2.12), accent + colours (2.14), right-aligned values (2.14.1). + +### Signals the car does not provide + +Checked 2026-10-17 against what actually reached InfluxDB (`schema.measurementFieldKeys`). The car +sends soc, est_battery_range, odometer, power, voltage, current, speed, hvac_setpoint, the four +tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does **not** send: + +- **Battery temperature:** read from `EV_BATTERY_AVERAGE_TEMPERATURE`, an Android 14 property; the + head unit runs Android 9, and the SAIC services offer none. +- **Cabin temperature:** `HVAC_TEMPERATURE_CURRENT` is not published; only the setpoint is. +- **Outside temperature:** not published by the vehicle (upstream noted the same). Upstream used + the head unit's weather service as a stand-in (`HeadUnitWeather`, removed in `ea8032a`); + restoring it was considered and declined. +- **Pack energy (`soe`) and capacity:** not published. The Live dashboard computes energy in pack + as SOC × usable capacity, from the hidden dashboard constant `usableKwh` (50.8 kWh for the + 51 kWh battery) in `k8s/grafana/dashboards/live.json`. + +## To do + +- [ ] **Grafana high availability needs another database.** Grafana keeps its own state (users, + sessions, preferences, provisioned-dashboard records, alerting state) in SQLite at + `/var/lib/grafana/grafana.db` on the `grafana-data` PVC. That is fine for one replica, and is + why `k8s/grafana.yaml` uses `replicas: 1` and the `Recreate` strategy: only one Grafana may + write the file. To run more than one replica, first move Grafana to PostgreSQL or MySQL + (`GF_DATABASE_TYPE`, `GF_DATABASE_HOST`, …); the replicas then share that database instead + of a volume. The car data itself is in InfluxDB and unaffected. +- [ ] **EVHardware's comments are partly French.** About 120 comment lines in the vendored + `EVHardware/` library (mostly `EVHardware.kt`, some tests), half-translated upstream. The + app, the docs and the build files are English. + +## Done + +- 2026-10-17 — Translated the app UI from Turkish to English. +- 2026-10-17 — Nix flake: `nix develop` provides JDK 21 and the Android SDK. `build-car` builds the + unstable APK and signs it with the AOSP test platform key, which the MG4 head unit uses + (cert SHA-256 `c8a2e9bc…192ab8`). +- 2026-10-17 — OTA update checks disabled by pointing at an unresolvable host (see To do). +- 2026-10-17 — Renamed to "Nils' MG4": package ID `com.stinnesbeck.nilsmg4` (`.unstable` for the car + build), APK names `NilsMG4-*.apk`. It installs next to the old FatihsMG4 app instead of replacing + it, and starts with empty settings and history. +- 2026-10-17 — Server rewritten for InfluxDB 2 with live telemetry and ABRP forwarding; Dockerfile; + Kubernetes manifests (not applied). +- 2026-10-17 — App 2.3.0: Server tab (URL, ingest token, installation id, config import), live + telemetry restored from before `ea8032a` and sent to the server, Upload-tab cadence controls + reconnected, dead switches removed. The removed ABRP classes' unit tests compile and pass again + (AbrpApi and HeadUnitWeather tests deleted with their classes). +- 2026-10-17 — Accent colour: MG red instead of muted gold. Web app renamed to Nils' MG4. +- 2026-10-17 — Dashboard translated to English (en-GB formatting); euro instead of lira in the + dashboard and the car app's charging tab. +- 2026-10-17 — Server rewritten in Go (API only); dashboards moved to Grafana with provisioned + InfluxDB datasource and three dashboards; the Next.js app (`web/`) removed. Per-host + cert-manager certificates dropped in favour of Traefik's default certificate. +- 2026-10-17 — Pairing by QR code: server 2.1.0 issues per-car tokens after approval on a phone; + app 2.4.0 shows the QR code and collects its token. Tested end to end against InfluxDB. +- 2026-10-17 — Incident: a commit swept the filled-in `secrets.example.yaml` (InfluxDB admin token, + INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in + the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside + `kubectl apply -f k8s/`). Filled-in secrets live only there from now on. +- 2026-10-18 — AGENTS.md rewritten as the entry point for agents: what the project is, rules, + confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces + upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator` + (Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked). +- 2026-10-18 — App 2.18.0: Settings → App → "Install from USB stick" (car build only): the newest + `NilsMG4-unstable-.apk` newer than the installed one in a stick's top folder, checked + against its `.sha256` and the package, installed with the OTA installer session. Tested on the + emulator: 2.18.0 installed a 2.18.1 from the storage's top folder silently, as the system user — + so the OTA install step should not need a confirmation either. +- 2026-10-18 — App 2.17.5: Server tab fits the car's window. The "Your private connection" card is + gone (its text was out of date); Import from file sits with Save and Test in one row; the + installation ID is shown as text, not edited, since it is derived from the head unit (a config + file can still carry another one over). The subtitle no longer says the server forwards to ABRP. +- 2026-10-18 — App 2.17.3: the Overview panel "Energy and temperature" is "Vehicle info" (2.17.4; "Odometer and climate" in 2.17.3): + it only holds those two, since the car publishes no pack energy or temperatures. +- 2026-10-18 — App 2.17.2: Wi-Fi auto-connect removed with its two permissions (ACCESS_ and + CHANGE_WIFI_STATE). Upstream used it to keep the head unit on a phone hotspot; this car's head + unit has its own connection. +- 2026-10-18 — App 2.17.1: Settings without the Wi-Fi and saved-networks cards, in three columns + (display and steering wheel | battery | charging price and app). Logs keeps only the upload log: the steering-key + reports, the car-colour probe and the screen line are gone, their questions answered. +- 2026-10-18 — App 2.17.0: "Speed, 5-min average" card under Speed. The top bar is one compact line + (title and subtitle); version, update check and About moved to an App card on the Settings tab. + The Overview fits the car's window without scrolling: checked on the emulator at 1778×640, the + layout height the car reports (less page padding, a lower tyre card). +- 2026-10-18 — App 2.16.4: drop-downs again, as Material exposed dropdown menus: their list leaves + the focus with the activity, so full screen stays (a Spinner's list is a focusable window of its + own without the full-screen flags). 2.16.3 had replaced them with buttons. +- 2026-10-18 — App 2.16.3: full screen stays while choosing or reading a dialog. Drop-downs (upload + frequency, tyre unit, accent colour) became buttons on the page: a drop-down is a window of its + own without the full-screen flags, so the car showed its bars while it was open. Dialogs are + shown unfocusable, given the activity's full-screen flags, then allowed focus + (`showKeepingFullScreen`); none of them takes typing. +- 2026-10-18 — From the car (2.16.2): screen 1920×720 px at 160 dpi with a 1778×720 px app + window, so EVProfile's 1280×480 does not apply; colour probe found only `vehicle_type = AS28`. + FIRMWARE.md, mise.toml and SPECS.md say so. +- 2026-10-18 — App 2.16.2: Logs → SCREEN shows the panel's pixels and density and the app + window in px and dp. The MG4's display figures disagree (1920×720 px used here; EVProfile lists + 1280×480, which is 1920×720 at 240 dpi in dp; upstream reported a 1778×720 window); FIRMWARE.md + lists all three until a photo of that line settles it. +- 2026-10-18 — EVProfile's licence checked: MIT, forked from SliDeeN/MG4Control and last merged + from it on 2026-08-02, inside MG4Control's MIT period (MIT up to 2.6.5 of 2026-08-05, GPL v3 + from 2.6.6 of 2026-09-02). Recorded in `LICENSE.md`. History scanned for secrets before the + repository went public: none. +- 2026-10-18 — Licence: Leon Kernan's `ABRP_Uploader` has been MIT since 2026-07-29 (its commit + `75d8f2bc`), so the "unresolved" notes are gone. `LICENSE` names every copyright holder in the + fork chain; `LICENSE.md` explains that `EVHardware/` is PolyForm Noncommercial, which makes the + built APK non-commercial. README, AGENTS.md and CONTRIBUTING.md updated. +- 2026-10-18 — App 2.16.1: the Overview reads the car every 0.5 s (was 2 s), on a background + thread; the consumption and charging trackers stay on the 2-second tick (they integrate over + time, and the charging tracker writes its session to storage on every sample). Note for + emulator tests: uiautomator can no longer dump the screen while the Overview is open, since + the UI is never idle. +- 2026-10-18 — Grafana's read-only InfluxDB token without a manual step: an init container in + the Grafana pod creates it with the admin token at every start, deletes the previous one and + leaves only the read-only token in a memory volume; the datasource reads it with + `$__file{/run/influx/token}`. InfluxDB ignores chosen token values (tested), so it cannot come + from the Secret. The `grafana-influx` Secret is gone. Tested in podman: InfluxDB 2.7.12, the + script as the Grafana user (twice: rotates, no pile-up), Grafana 13.2.3 datasource healthy. +- 2026-10-18 — App 2.16.0, server 2.6.0: over-the-air updates from this repository. `publish-car` + creates the pre-release `car-` on the private forge with the APK and its `.sha256`; + the server relays releases and attachments to the car (`/v1/app/…`, read with a read-only + `FORGE_TOKEN`); the app checks them through its server with its own token, allows exactly the + server's host, downloads itself (no DownloadManager, so the token stays in the app), verifies + SHA-256 and package, and tries a PackageInstaller session before falling back to Downloads. + `OTA.md` rewritten for this flow. Not yet tried on the car. +- 2026-10-18 — App 2.15.0: unsent live samples survive a restart. The queue is saved to + `files/telemetry_queue.json` (AtomicFile) at most every 5 minutes and only after a change, and + at once when the service stops; it is restored, in order and within its 2000-sample cap, when + the service starts. A killed process loses at most 5 minutes. Tested on the emulator across a + reboot (6 queued samples back). +- 2026-10-18 — App 2.14.4: every UI text is in `strings.xml` (Charging, Consumption and Settings + labels, toasts, the trip-reset dialog, charging and Wi-Fi states, the daily summary, the "—" + placeholder); lint reports no hard-coded text. Upload-log entries stay in code as log text. +- 2026-10-18 — Translated the rest: `mise.toml`, the comments in `app/build.gradle`, `OTA.md`'s + message table (now the updater's current English messages), the Turkish messages quoted in + `CHANGELOG.md` and `OTA.md` (glossed), two test messages, the ignition-state comment in + `EVHardware.kt`. +- 2026-10-18 — About dialog: link and QR code point to https://git.stinnesbeck.com/nils/mg4 + (were Fatih's GitHub). Status section brought up to date. +- 2026-10-18 — App 2.14.2: "Range, 5-min average", like the usage and power cards (was "Range at 5-min average"). +- 2026-10-18 — App 2.14.1: Overview values and their notes right-aligned, with tabular + (equal-width) digits, so 9 → 10 → 100 no longer shifts the text; labels stay left. +- 2026-10-18 — App 2.14.0: accent colour selectable after the MG4 paints (Settings → Display): + Dynamic Red (default), Volcano Orange, Holborn Blue, Racing Green, Camden Grey, Monument Silver, + Arctic White, Black Pearl; white, silver and black become light greys and a pearl blue-grey, + since they cannot be an accent on black. The accent palette is theme attributes + (`attrs_accent.xml`, overlays in `accents.xml`); a change recreates the activity on the same + page. Android 9 crashes on gradients with theme colours, so the battery card is layered. + Logs tab: "Look for the car colour" lists system properties that mention colour, paint or car + model, to find out whether the paint colour can be read (nothing on the emulator). +- 2026-10-18 — App 2.13.0: tyre pressures in bar by default, kPa or psi selectable (Settings → + Display; display only, the car and the server keep kPa). "Power, 5-min average" card under + Power: time-weighted mean battery power, standing included. +- 2026-10-18 — App 2.12.1: no scroll bars (the 2-second refresh kept them from ever fading); + "Energy since start" tile (net kWh of the trip, recuperation counted back) left of "Usage since + start". +- 2026-10-18 — App 2.12.0, server 2.5.0: settings entered on a phone. "Enter on phone" shows a QR + code; the phone form on the server (approval password) takes the ABRP credentials, charging + price and usable capacity, and the car collects them once (RFC 8628-style, like pairing; values + in server memory only, at most 10 minutes). The config file also takes `charging_price` and + `usable_kwh`. The QR dialog is shared with pairing (`PhoneQrDialog`). Tested end to end with + curl against the server image and InfluxDB; the app side on the emulator only up to the dialog. +- 2026-10-18 — App 2.11.1, server 2.4.0: ABRP forwarding removed from the server. The car and the + server use the same mobile connection, so a server fallback for ABRP bought nothing. The car no + longer sends `abrpSentUtc`; the server ignores unknown fields, so older apps keep working. +- 2026-10-18 — App 2.11.0, server 2.3.0: + - Live data goes to ABRP straight from the car when ABRP credentials are set in the app + (Live data tab or config file: `abrp_api_key`, `abrp_token`), so ABRP keeps getting it + while the server is down. The car tells the server the newest sample ABRP took + (`abrpSentUtc`); the server forwards only newer ones. Without credentials in the app the + server forwards as before. This deliberately puts ABRP credentials in the car again + (encrypted, never logged). + - Settings regrouped: Live data (on/off, ABRP, frequency), Server, Settings (full screen, voice + key, usable capacity and SoH from Consumption, charging price from Charging, Wi-Fi, which + lost its own tab). Charging and Live data have their own icons. +- 2026-10-18 — App 2.10.2: the range shows "∞" instead of "> 999 km" while recuperating. +- 2026-10-18 — App 2.10.1: net regeneration (recuperating downhill) shows as negative usage, and + the range as "> 999 km" with "while recuperating", instead of "needs a few minutes of driving". +- 2026-10-18 — App 2.10.0: "Range since start" replaces the range at current power. It uses + the consumption since the car was switched on, including standing with the climate on, and a + new trip starts after 10 minutes without samples or when charging. Usage (kWh/100 km) for both + estimates moved into cards of their own under the range tiles, which fit on the full-screen + 1920x720 overview without scrolling. +- 2026-10-18 — Code review fixes. App 2.9.0, server 2.2.0, Grafana token: + - The server drops a bad value (or a sample without a plausible time) instead of rejecting + the batch, and answers 200 with what it dropped; the app sends only values within the + server's ranges (speed as a magnitude) and no samples before its clock is set. Before, one + reversing sample cost the whole queued batch. + - Pairing: the token is handed out and accepted only after the device is saved; wrong-password + lockouts and open-request limits are per client address; a paired token only serves its + own installation id (403 otherwise). + - ABRP forwarding skips samples stamped ahead of the server's clock, which used to block it. + - App: settings written in plaintext during a keystore failure move back into encrypted + storage; the installation id is derived from ANDROID_ID, so a reinstall keeps its history; + the imported config file is deleted; GPS stops when live telemetry is off and pauses while + parked and idle; range estimates use the monotonic clock; one HTTP helper (`ServerHttp`) + for all server calls; the last Turkish strings translated. + - Grafana reads InfluxDB with its own read-only token (`grafana-influx` Secret, created by + hand: `k8s/README.md`). +- 2026-10-18 — README rewritten for Nils' MG4. `OTA.md`, `mise.toml` and + `docs/MG4_PLATFORM_INSTALL.md` use the new package ID and `NilsMG4-*.apk` names; `OTA.md` notes + that updates are switched off. `CHANGELOG.md` keeps the old ID as upstream history. +- 2026-10-18 — App 2.8.2: full screen uses the whole display. The MG4 hid its bars but left a black + band where they were: LAYOUT_STABLE kept reporting the bars' insets and the fitsSystemWindows root + padded for them. Both are off in full screen now. +- 2026-10-17 — App 2.8.1: three-part version names (`2.8.1-unstable`, `NilsMG4-unstable-2.8.1.apk`). + Upstream's fourth part was its CI build number; the version code keeps the x100000 factor so it + never falls below codes already installed. +- 2026-10-17 — App 2.8.0: immersive full screen (switch in the Upload tab, default on). Hides the + system bars on the emulator; whether SAIC's own bars on the MG4 can be hidden is only known once + tried on the car. +- 2026-10-17 — App 2.7.1: the voice key works. The car's key report, read off the on-screen log: + `android.intent.extra.hardkey.keycode` (Integer), `.down` (Boolean, a report per press and per + release), `.longpress` (Boolean). 2.7.0 looked only for short names and never found the code. + Floating button removed (it did not show over the MG4 launcher). +- 2026-10-17 — App 2.7.0: a double press of the steering wheel's voice key (code 287) opens the + app, from the head unit's `com.saic.keyevent.hardkey.report` broadcast. Its extras are not + documented; the usual names are tried and every report is listed raw on the Logs tab. The + floating button (2.6.0) did not show over the MG4 launcher. Pages no longer jump back to the + top on every refresh (`StableScrollView`), and the Vehicle tab shows all six value tiles, + including both range estimates, in one row at the top. +- 2026-10-17 — App 2.6.0: floating button to open the app, because the MG4 launcher does not list + it. Drawn over other apps (needs the overlay permission, which the car build has through the + system UID), draggable, hidden while the app is open, switch in the Upload tab. Tested on an + Android 9 AOSP emulator with the car APK (installs as UID 1000 there too), at 1920x1080 and at the + head unit's real 1920x720 / 160 dpi, where all tabs and the button were checked as well. +- 2026-10-17 — App 2.5.0: range estimates at current and 5-minute average power (Vehicle tab). +- 2026-10-17 — Live dashboard reworked around the signals the car sends: Climate setpoint, Last + update and a computed Energy in pack replace the empty temperature tiles; Battery voltage and + current replaces the Temperatures chart. +- 2026-10-17 — Temporary on-screen diagnostics (b2a5b32) to debug blank values without adb; + removed again (1002962) once values showed on the car. Revert 1002962 to bring them back. + +## Installing on the car + +``` +nix develop +build-car +adb push build/car/NilsMG4-unstable-.apk /data/local/tmp/update.apk +adb shell "pm install -r -f -d -t /data/local/tmp/update.apk" +``` + +To remove the old upstream app: `adb uninstall com.evsuite.abrp.unstable`. diff --git a/app/build.gradle b/app/build.gradle index 8a597c2..1e21cdd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,14 +10,14 @@ android { } } - // Signature de l'APK — rien à voir avec la clé d'API ABRP de l'utilisateur. - // Ces valeurs servent UNIQUEMENT au build : elles prouvent que l'APK vient bien de - // nous et n'entrent jamais dans l'application. La clé d'API et le token ABRP, eux, - // sont saisis dans l'app et stockés chiffrés sur l'appareil (voir SecurePrefs). + // APK signing — nothing to do with the user's ABRP credentials. These values are ONLY for + // the build: they prove the APK comes from us and never enter the app. The ABRP API key and + // token are entered in the app and stored encrypted on the device (see SecurePrefs). + // The car build is signed by build-car (flake.nix) with the AOSP platform key instead. // - // Un keystore contient plusieurs clés : SIGNING_STORE_PASSWORD ouvre le conteneur, - // SIGNING_KEY_ALIAS choisit la clé, SIGNING_KEY_PASSWORD ouvre cette clé-là. - // Lues depuis l'environnement (CI) ou gradle.properties local — JAMAIS commitées. + // A keystore holds several keys: SIGNING_STORE_PASSWORD opens the container, + // SIGNING_KEY_ALIAS picks the key, SIGNING_KEY_PASSWORD opens that key. + // Read from the environment (CI) or a local gradle.properties — NEVER committed. def keystorePath = System.getenv("SIGNING_KEYSTORE") ?: project.findProperty("signing.keystore") signingConfigs { if (keystorePath != null && file(keystorePath).exists()) { @@ -31,33 +31,27 @@ android { } defaultConfig { - applicationId "com.evsuite.abrp" + applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 56 - versionName "2.2.39" + versionCode 95 + versionName "2.18.0" - // MongoDB credentials never ship in the APK. The app only talks to the small - // authenticated API in server/, which owns the database connection. - buildConfigField "String", "CONSUMPTION_API_URL", "\"${System.getenv('CONSUMPTION_API_URL') ?: ''}\"" - buildConfigField "String", "CONSUMPTION_API_TOKEN", "\"${System.getenv('CONSUMPTION_API_TOKEN') ?: ''}\"" - // Fixed installation identity: baked into every build so an APK wipe cannot orphan - // the cloud history under a new random UUID. The car's own consumption history lives - // under this id in the daily_consumption collection. - buildConfigField "String", "CONSUMPTION_INSTALLATION_ID", "\"226b1614-4d18-48f0-8938-3609cfa3db93\"" + // Nothing about the server ships in the APK: its URL and ingest token are entered in + // the app's Server tab (ServerConfig), and the database is only reachable by the server. testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } - // null quand aucun keystore n'est configuré : le build reste possible sur une - // machine sans secrets (APK non signé), au lieu d'échouer. + // null when no keystore is configured: the build still works on a machine without + // secrets (unsigned APK) instead of failing. def platformSigning = signingConfigs.findByName("platform") - // Deux canaux de distribution : - // - stable : diffusé en release taguée, AUCUN auto-update. L'utilisateur installe - // lui-même ; rien dans l'app ne va chercher un APK. - // - unstable : pré-releases publiées à chaque push sur master, avec OTA pour que les testeurs - // soient à jour sans manipulation. Installable à côté du stable. + // Two distribution channels: + // - stable: no self-update at all. The user installs it; nothing in the app fetches an + // APK (the updater is not even in this APK). + // - unstable: the car build (system UID, platform-signed), with the OTA updater, so the + // car can update itself from published pre-releases. Installs next to stable. flavorDimensions += "channel" productFlavors { stable { @@ -67,16 +61,13 @@ android { unstable { dimension "channel" applicationIdSuffix ".unstable" - // Android uses versionCode, not versionName, to order package updates. Give - // every rolling CI build a strictly increasing code so Package Installer does - // not treat a newer unstable APK as the same package version. - versionCode defaultConfig.versionCode * 100000 + - (project.findProperty('unstableBuild') ?: '0').toString().toInteger() - // Le numéro de build fait partie du COEUR numérique de la version - // ("1.0.42-unstable"), pas du suffixe : l'updater compare des segments - // numériques, donc deux builds unstable doivent différer avant le premier tiret. - // -PunstableBuild= est passé par la CI ; 0 en local. - versionName "${defaultConfig.versionName}.${project.findProperty('unstableBuild') ?: '0'}" + // Three-part versions, the same as defaultConfig ("2.8.1-unstable"). Upstream added + // a fourth part for its CI build number; there is no CI here and the OTA updater is + // off. The version code keeps upstream's x100000 so it never drops below codes + // already installed on the car (2.8.0.0 was 6600000): a lower code is a downgrade, + // which the installer refuses. + versionCode defaultConfig.versionCode * 100000 + versionName defaultConfig.versionName versionNameSuffix "-unstable" buildConfigField "boolean", "OTA_ENABLED", "true" } @@ -112,25 +103,28 @@ android { } } -// Version publiée par la CI pour composer le nom de l'asset APK : ce nom DOIT être -// comparable par l'updater ("FatihsMG4-unstable-1.0.42.apk"). Le tag de la -// pré-release est la constante "unstable", écrasée à chaque build. +// The version build-car puts in the APK's file name ("NilsMG4-unstable-2.8.1.apk"), which +// is also the name the OTA updater reads a release's version from. tasks.register('printUnstableVersion') { doLast { - println "${android.defaultConfig.versionName}.${project.findProperty('unstableBuild') ?: '0'}" + println android.defaultConfig.versionName } } -// Nomme les APK par canal pour que les artefacts de release soient lisibles. +// Names the APKs by channel so the build outputs are readable. androidComponents { onVariants(selector().all()) { variant -> variant.outputs.each { output -> if (variant.buildType == "release") { - // Le canal unstable garde un nom de fichier stable : l'updater identifie - // la version par le tag de la pré-release, jamais par le nom d'APK. + // A fixed name for the unstable release output. What is published for the car + // comes from build-car, named NilsMG4-unstable-.apk: that file name is + // where the updater reads a release's version from. output.outputFileName.set(variant.flavorName == "unstable" - ? "FatihsMG4-unstable.apk" - : "FatihsMG4-${variant.flavorName}-${output.versionName.get()}.apk") + ? "NilsMG4-unstable.apk" + : "NilsMG4-${variant.flavorName}-${output.versionName.get()}.apk") + } else { + // Debug builds: NilsMG4-stable-debug.apk, NilsMG4-unstable-debug.apk. + output.outputFileName.set("NilsMG4-${variant.flavorName}-${variant.buildType}.apk") } } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 608eaba..68f3033 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,13 +8,12 @@ + + + + + - - - @@ -52,7 +51,8 @@ + android:exported="false" + android:foregroundServiceType="dataSync|location" /> + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_input_panel.xml b/app/src/main/res/drawable/bg_input_panel.xml index 1b78256..8eb88fe 100644 --- a/app/src/main/res/drawable/bg_input_panel.xml +++ b/app/src/main/res/drawable/bg_input_panel.xml @@ -1 +1 @@ - + diff --git a/app/src/main/res/drawable/bg_topbar.xml b/app/src/main/res/drawable/bg_topbar.xml index 1d5b3ba..1a26e08 100644 --- a/app/src/main/res/drawable/bg_topbar.xml +++ b/app/src/main/res/drawable/bg_topbar.xml @@ -9,7 +9,7 @@ - + diff --git a/app/src/main/res/drawable/bg_version_badge.xml b/app/src/main/res/drawable/bg_version_badge.xml index 81362e0..d87eb68 100644 --- a/app/src/main/res/drawable/bg_version_badge.xml +++ b/app/src/main/res/drawable/bg_version_badge.xml @@ -1,6 +1,6 @@ - - + + diff --git a/app/src/main/res/drawable/ic_chevron_down.xml b/app/src/main/res/drawable/ic_chevron_down.xml index 8cd2a5d..c290ce9 100644 --- a/app/src/main/res/drawable/ic_chevron_down.xml +++ b/app/src/main/res/drawable/ic_chevron_down.xml @@ -2,5 +2,5 @@ + android:strokeColor="?attr/evAccent" android:strokeWidth="1.8" android:strokeLineCap="round" android:strokeLineJoin="round" /> diff --git a/app/src/main/res/drawable/ic_info.xml b/app/src/main/res/drawable/ic_info.xml index 91a111e..b61bd65 100644 --- a/app/src/main/res/drawable/ic_info.xml +++ b/app/src/main/res/drawable/ic_info.xml @@ -1 +1 @@ - + diff --git a/app/src/main/res/drawable/ic_tab_charging.xml b/app/src/main/res/drawable/ic_tab_charging.xml new file mode 100644 index 0000000..c331058 --- /dev/null +++ b/app/src/main/res/drawable/ic_tab_charging.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_tab_live.xml b/app/src/main/res/drawable/ic_tab_live.xml new file mode 100644 index 0000000..0db73a1 --- /dev/null +++ b/app/src/main/res/drawable/ic_tab_live.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_tab_log.xml b/app/src/main/res/drawable/ic_tab_log.xml index 67ce1ca..feb44c6 100644 --- a/app/src/main/res/drawable/ic_tab_log.xml +++ b/app/src/main/res/drawable/ic_tab_log.xml @@ -1,5 +1,5 @@ - + + + + + + diff --git a/app/src/main/res/drawable/ic_tab_service.xml b/app/src/main/res/drawable/ic_tab_service.xml index 40bd181..14b498e 100644 --- a/app/src/main/res/drawable/ic_tab_service.xml +++ b/app/src/main/res/drawable/ic_tab_service.xml @@ -1,5 +1,5 @@ - + - - - - diff --git a/app/src/main/res/drawable/ic_vehicle_outline.xml b/app/src/main/res/drawable/ic_vehicle_outline.xml index f1e7624..806b126 100644 --- a/app/src/main/res/drawable/ic_vehicle_outline.xml +++ b/app/src/main/res/drawable/ic_vehicle_outline.xml @@ -1,5 +1,5 @@ - - - + + + diff --git a/app/src/main/res/layout-w1000dp/activity_main.xml b/app/src/main/res/layout-w1000dp/activity_main.xml index c1cae25..ccf2bf9 100644 --- a/app/src/main/res/layout-w1000dp/activity_main.xml +++ b/app/src/main/res/layout-w1000dp/activity_main.xml @@ -4,25 +4,23 @@ - + - - - + + + + - - - - - - - - + + + + diff --git a/app/src/main/res/layout-w1000dp/pane_service.xml b/app/src/main/res/layout-w1000dp/pane_service.xml index 9eced4d..1298c51 100644 --- a/app/src/main/res/layout-w1000dp/pane_service.xml +++ b/app/src/main/res/layout-w1000dp/pane_service.xml @@ -1,24 +1,41 @@ - + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - + + + + + + + + + + + + + - + diff --git a/app/src/main/res/layout-w1000dp/pane_settings.xml b/app/src/main/res/layout-w1000dp/pane_settings.xml new file mode 100644 index 0000000..92822d7 --- /dev/null +++ b/app/src/main/res/layout-w1000dp/pane_settings.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-w1000dp/pane_vehicle.xml b/app/src/main/res/layout-w1000dp/pane_vehicle.xml index 676aa76..ea5ac85 100644 --- a/app/src/main/res/layout-w1000dp/pane_vehicle.xml +++ b/app/src/main/res/layout-w1000dp/pane_vehicle.xml @@ -1,6 +1,64 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -17,45 +75,25 @@ - + - + - + - + - + - - - - - - - - - - - - - - - - - - - - - + diff --git a/app/src/main/res/layout-w1000dp/pane_wifi.xml b/app/src/main/res/layout-w1000dp/pane_wifi.xml deleted file mode 100644 index a6485be..0000000 --- a/app/src/main/res/layout-w1000dp/pane_wifi.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout-w600dp/dialog_about.xml b/app/src/main/res/layout-w600dp/dialog_about.xml index d0994a6..73569f8 100644 --- a/app/src/main/res/layout-w600dp/dialog_about.xml +++ b/app/src/main/res/layout-w600dp/dialog_about.xml @@ -4,14 +4,14 @@ - + - + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 4a92f4f..1a8270b 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -2,22 +2,20 @@ - - - - - - - - + + + + - - - + + + + diff --git a/app/src/main/res/layout/dialog_about.xml b/app/src/main/res/layout/dialog_about.xml index 5b4a7ac..d552f4c 100644 --- a/app/src/main/res/layout/dialog_about.xml +++ b/app/src/main/res/layout/dialog_about.xml @@ -2,13 +2,13 @@ - + - + diff --git a/app/src/main/res/layout/dropdown_item.xml b/app/src/main/res/layout/dropdown_item.xml new file mode 100644 index 0000000..623a16f --- /dev/null +++ b/app/src/main/res/layout/dropdown_item.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/layout/pane_charging.xml b/app/src/main/res/layout/pane_charging.xml index 4d561aa..02e6819 100644 --- a/app/src/main/res/layout/pane_charging.xml +++ b/app/src/main/res/layout/pane_charging.xml @@ -1,31 +1,24 @@ - + - + - - - - - - - - - + + + + + + + + + - - - - - - - - + - + - + diff --git a/app/src/main/res/layout/pane_consumption.xml b/app/src/main/res/layout/pane_consumption.xml index bb614af..1d59919 100644 --- a/app/src/main/res/layout/pane_consumption.xml +++ b/app/src/main/res/layout/pane_consumption.xml @@ -1,43 +1,35 @@ - + - - + + - - - - - + + + + + - - - - - - + + + + + + - + - + - - - - - - - - - - - + + + - + diff --git a/app/src/main/res/layout/pane_log.xml b/app/src/main/res/layout/pane_log.xml index 31ea5d8..7bfc7fc 100644 --- a/app/src/main/res/layout/pane_log.xml +++ b/app/src/main/res/layout/pane_log.xml @@ -1,8 +1,8 @@ - + - + diff --git a/app/src/main/res/layout/pane_server.xml b/app/src/main/res/layout/pane_server.xml new file mode 100644 index 0000000..51b5762 --- /dev/null +++ b/app/src/main/res/layout/pane_server.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/pane_service.xml b/app/src/main/res/layout/pane_service.xml index 9d8557f..b53b64f 100644 --- a/app/src/main/res/layout/pane_service.xml +++ b/app/src/main/res/layout/pane_service.xml @@ -1,20 +1,32 @@ - + - - + - - - - + + + + + + + + + + + + + + + - + + + @@ -22,4 +34,4 @@ - + diff --git a/app/src/main/res/layout/pane_settings.xml b/app/src/main/res/layout/pane_settings.xml new file mode 100644 index 0000000..437f3e5 --- /dev/null +++ b/app/src/main/res/layout/pane_settings.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/pane_vehicle.xml b/app/src/main/res/layout/pane_vehicle.xml index f4cc6c7..5993153 100644 --- a/app/src/main/res/layout/pane_vehicle.xml +++ b/app/src/main/res/layout/pane_vehicle.xml @@ -1,6 +1,61 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -16,44 +71,24 @@ - + - + - + - + - + - - - - - - - - - - - - - - - - - - - - - + diff --git a/app/src/main/res/layout/pane_wifi.xml b/app/src/main/res/layout/pane_wifi.xml deleted file mode 100644 index 80d7398..0000000 --- a/app/src/main/res/layout/pane_wifi.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/spinner_dropdown.xml b/app/src/main/res/layout/spinner_dropdown.xml deleted file mode 100644 index 562c36f..0000000 --- a/app/src/main/res/layout/spinner_dropdown.xml +++ /dev/null @@ -1,8 +0,0 @@ - - diff --git a/app/src/main/res/layout/spinner_value.xml b/app/src/main/res/layout/spinner_value.xml deleted file mode 100644 index 59ab3bf..0000000 --- a/app/src/main/res/layout/spinner_value.xml +++ /dev/null @@ -1,8 +0,0 @@ - - diff --git a/app/src/main/res/values/accents.xml b/app/src/main/res/values/accents.xml new file mode 100644 index 0000000..b781709 --- /dev/null +++ b/app/src/main/res/values/accents.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/attrs_accent.xml b/app/src/main/res/values/attrs_accent.xml new file mode 100644 index 0000000..518aa16 --- /dev/null +++ b/app/src/main/res/values/attrs_accent.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 35475b4..6fdc37f 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,5 +1,7 @@ - + #000000 #121212 @@ -7,11 +9,11 @@ #363B3D #F4F3EF #BDC4C6 - #D8C39A - #17140E - #A0E2E0 - #103436 - #28241B + #E0424C + #1A0B0C + #FF9A9E + #3A1215 + #2A1416 #A4DCC1 #E9CA96 #FFB4AB diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 194577a..5ea54dc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,86 +1,145 @@ - FatihsMG4 + Nils\' MG4 - ABRP - Aktarım - Genel - Tüketim - Şarj - Tüketim - Başlangıç, motor, tüm zamanlar ve trip sayaçları - Şarj - Canlı batarya değerleri ve SOC/güç eğrisi - Wi-Fi - Kayıtlar + Live data + Overview + Consumption + Charging + Consumption + Since start, motor, lifetime and trip counters + Charging + Live battery values and SOC/power curve + Server + Settings + Logs - - Wi-Fi Bağlantısı - Bağlı ağ - Durum - Şimdi Bağlan - Arka planda otomatik bağlan - Yükleme servisi çalışırken bağlantı koptuğunda kayıtlı ağlara kendi kendine bağlanmayı dener. - Kayıtlı Ağlar - Kayıtlı ağ okunamadı - Bağlı değil - Canlı Araç Verileri - Lastik Basınçları - Şarj (SOC) - Menzil - Güç - Hız - Kilometre - Batarya Sıcaklığı - Kabin Sıcaklığı - Klima Ayarı - Batarya Kapasitesi - Şarj Durumu - Ön Sol - Ön Sağ - Arka Sol - Arka Sağ - Configuration - ABRP API Key - 8cfc314b-03cd-4efe-ab7d-4431cd8f2e2d - Default: open-source ABRP telemetry key. You may replace it with your own. - Enter your ABRP API key first - ABRP User Token - Find your token in the ABRP app: Settings → Connections → Generic (Manual Entry) - Show or hide API key - Show or hide user token - Kaydet - Bağlantıyı test et - Dosyadan al + Live Vehicle Data + Tire Pressures + Charge (SOC) + Range + Power + Range since start + Range, 5-min average + Usage since start + Energy since start + Power, 5-min average + %1$.1f kW + needs a minute of data + Tyre pressure unit + Accent colour + %1$.1f kWh + net, after recuperation + once the car is on + Usage, 5-min average + %1$.1f kWh/100 km + > %1$d km + needs a few minutes of driving + while recuperating + once the car has moved + over %1$.1f km + over the last 5 minutes + Usable battery capacity + Save capacity + Used for the range estimates on the Overview. The 51 kWh MG4 battery has 50.8 kWh usable, the 64 kWh one 61.7, the 77 kWh one 74.4. + Usable capacity saved + Enter a value between 10 and 150 kWh + Speed + Odometer + Battery Temperature + Cabin Temperature + Climate Setpoint + Battery Capacity + Charging Status + Front Left + Front Right + Rear Left + Rear Right + Save + Test connection + Import from file + Installation ID %1$s · derived from the head unit; your data is stored under it + + + Car server + Server URL + For example https://car.k8s.stinnesbeck.com + Ingest token + The server\'s INGEST_TOKEN, at least 24 characters + Enter an https:// URL + Pair with phone + Shows a QR code: scan it with your phone and approve with the approval password. The token below is then filled in for you. + Pair with phone + Scan the QR code with your phone, or open %1$s and enter the code %2$s: + Waiting for approval… + Contacting the server… + Paired — this car now has its own token + Pairing was denied on the phone + The code expired — press Pair with phone again + Pairing is not enabled on the server (no APPROVAL_PASSWORD) + Pairing failed: %1$s + Close + The server has no pairing (HTTP 404). It needs server version 2.1.0 or newer. + Server name %1$s not found. Is it in public DNS? + Server %1$s not reachable + Server answered HTTP %1$d + Cancel + QR code to approve this car on your phone + The token must be at least 24 characters, without spaces + Not a valid ID (UUID) + Server settings saved + Server not configured — open the Server tab - Config imported — press Test to verify + Config imported and saved — press Test to verify + Config imported and saved; the file was deleted + Config imported and saved, but the file could not be deleted. It holds your token: delete %1$s Could not read that file - No ABRP settings found in that file + The charging price or usable capacity in the file is not plausible + No settings found in that file No config file found. Copy it to %1$s Testing… - Connected — API responding OK - Auth failed — check your API key and token + Connected — server accepts the token + Token rejected — check it against the server\'s INGEST_TOKEN Location denied — telemetry will have no position. Grant it in Settings to send GPS to ABRP. Vehicle data denied — no battery %%, speed or range will be sent. Grant it in Settings. Location and vehicle data denied — only a timestamp will be sent. Grant both in Settings. No internet connection Connection timed out Server error (HTTP %1$d) - Enter your ABRP token first - Upload Service - Enable background telemetry - Upload telemetry to ABRP - Turn this off when DiPlay sends ABRP data. Local consumption tracking and background telemetry keep running. - Start automatically with the car - Uploading resumes on its own when the head unit powers up. Turn this off to start it by hand from this screen. + Full screen + Hides the car\'s top and bottom bars while this app is open. Swipe in from the top or bottom edge to show them briefly, e.g. for the climate controls. + Double-press the voice key to open this app + Press the voice key on the steering wheel twice quickly. The voice assistant may still react to the presses. + Send live data + To your server, which stores it, and straight to ABRP when the ABRP credentials below are set. Consumption and charging tracking keep running when this is off. + The car sends live data to ABRP itself with your ABRP API key and user token. Without them, ABRP gets no live data; your server only stores it. + ABRP API key + ABRP user token + Saved: the car sends live data to ABRP directly + Cleared: ABRP gets no live data + Enter both, without spaces, or leave both empty + Checking with ABRP… + ABRP accepted these credentials + ABRP refused: HTTP %1$d + ABRP not reachable: %1$s + Set both first + Enter on phone + Type the ABRP credentials, charging price and usable capacity on your phone instead: a QR code opens a form on your server. + Enter settings on your phone + Scan the QR code with your phone, or open %1$s and enter the code %2$s. Fill in what you want to change and send it with the approval password. + Waiting for the phone… + Set up the server first (Server tab): the form for the phone runs on your server. + Not available on the server: it needs version 2.5.0 or newer and an APPROVAL_PASSWORD + The code expired — press Enter on phone again + Settings from the phone saved + Some values from the phone were not plausible and were not saved - Service not running Upload frequency @@ -100,14 +159,15 @@ Stopped Starting… Running - Tracking locally — ABRP upload off - Tracking locally — ABRP upload off + Running — live data to ABRP only, no server set up + Tracking locally — live telemetry off + Tracking locally — live telemetry off In error — last %1$d uploads failed Recent uploads No upload attempted yet. - Şimdi yükle + Upload now Starting… Running — waiting for first upload Last upload: %1$s (%2$s) @@ -118,21 +178,91 @@ Shows vehicle telemetry upload status ABRP Uploader Stop - Güncelleme kuruldu — uygulamayı yeniden başlatın - Kurulum başarısız: %1$s - Güncelleme kontrol et - Yeni OTA sürümü kontrol ediliyor… - Yazılım güncellemesi - İndiriliyor ve doğrulanıyor… - İndiriliyor… %1$d%% + Update installed — restart the app + Installation failed: %1$s. The APK is in Downloads. + Check for updates + Checking for a new OTA version… + Looking on the USB stick… + Install from USB stick + Software update + Check for updates + About Nils\' MG4 + Version %1$s + Speed, 5-min average + %1$.0f km/h + Downloading and verifying… + Downloading… %1$d%% About - Fatih\'sMG4 Hakkında + About Nils\' MG4 Version %1$s - Fork of malys / EVABRPUploader\nand Leon Kernan / ABRP_Uploader - github.com/fatihdonmezdev/FatihsMG4 + Fork of fatihdonmezdev / FatihsMG4,\nmalys / EVABRPUploader\nand Leon Kernan / ABRP_Uploader + git.stinnesbeck.com/nils/mg4 MG4 EH32 · SWI69 - FatihsMG4 logo + Nils\' MG4 logo QR code for the project repository unknown Close + + + Not charging + SOC + Start SOC + Battery voltage + Battery current + Charging power + Net energy into battery + Drawn from grid (+10%) + Charging time + Total cost + CHARGING CURVE + No charging measurements yet + CONSUMPTION PERIOD + Lifetime + Last 7 days + Last 30 days + Trip A + Trip B + Distance + Net energy + Average consumption + Average speed + Duration + SOC change + Reset selected trip + Last 7/30 days, lifetime and Trip A/B are stored permanently on the device; app and OTA updates do not erase them. + DAILY LOG · LAST 4 MONTHS + Pick a date + No daily data yet + Manual SoH (%) + % + Save SoH value + This manual value is stored on the device and sent to your server with the daily totals. + kWh + Unit price + €/kWh + Save price + Reset %1$s? + This counter\'s recorded consumption history will be permanently deleted. + Reset + SoH saved + Enter a value between 1 and 100 + Charging price saved + Enter a valid €/kWh value + DC fast charging + AC charging (not metered) + Last charge + No records for this date + Distance %1$.1f km\nEnergy %2$.2f kWh\nAverage %3$s\nDuration %4$d h %5$02d min\nSOC change %6$.1f %% + Consumption refreshed + AC charging ♥ + DC fast charging ♥ + Connecting… + Plugged in, not charging + Stopped + Status %1$d + Connected ♥ + Not connected + + — + Refresh diff --git a/app/src/main/res/values/strings_design.xml b/app/src/main/res/values/strings_design.xml index 8564ab9..1c8dc16 100644 --- a/app/src/main/res/values/strings_design.xml +++ b/app/src/main/res/values/strings_design.xml @@ -1,28 +1,31 @@ - Batarya + Battery VHAL Probe MG⁴ - Fatih’s MG4 - ELEKTRİKLE YOLDA - Sürüşe bir bakış. - Aracından gelen veriler, tek bir yerde. - Rotana bağlan. - ABRP hesabınla aracını buluştur. - Sen yola odaklan. - Verilerin arka planda güncel kalsın. - Bağlantıda kal. - Yolculuğunun internet bağlantısı. - Her aktarım, burada. - Son gönderimlerin durumu ve ayrıntıları. - BATARYA - ENERJİ VE SICAKLIK - LASTİK BASINÇLARI - HESAP BAĞLANTISI - Sana özel bağlantı. - Token, aracını ABRP hesabına bağlar. Bilgilerin bu cihazda şifreli saklanır. - Okunamayan veriler — olarak gösterilir. - AKTARIM KONTROLÜ - GÜNCELLEME SIKLIĞI - Lastik basınçlarının araç üzerindeki konumları + Nils’ MG4 + ON THE ROAD, ELECTRIC + Your drive at a glance. + Data from your car, all in one place. + Where your data goes. + Your own server stores it; the car sends live data to ABRP itself. + Live data. + Where your car\'s live data goes, and how often. + Settings. + Display, battery, charging price and updates. + Every upload, right here. + Status and details of recent uploads. + BATTERY + VEHICLE INFO + TIRE PRESSURES + SERVER LINK + Values that cannot be read are shown as —. + LIVE DATA + ABRP, DIRECTLY FROM THE CAR + DISPLAY AND STEERING WHEEL + BATTERY + APP + CHARGING PRICE + UPDATE FREQUENCY + Tire pressure positions on the vehicle diff --git a/app/src/main/res/values/styles_app.xml b/app/src/main/res/values/styles_app.xml index cfeef15..7c74ff9 100644 --- a/app/src/main/res/values/styles_app.xml +++ b/app/src/main/res/values/styles_app.xml @@ -39,19 +39,32 @@ 16dp 16dp 16dp - @color/ev_accent + ?attr/evAccent @color/ev_text_secondary @style/Text.EV.Secondary + + @@ -96,4 +96,5 @@ + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index d6cc7f8..9432ab5 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -3,15 +3,23 @@
+

Car settings

+{{if .Done}} +

{{.Message}}

+{{else}} +

Fill in what you want to change; empty fields leave the car's value as it is. The car picks the values up within a few seconds.

+
+{{if .Code}}
{{.Code}}
+{{else}} +{{end}} + + + + +

The car needs both to send live data to ABRP.

+ + + + + + + +{{if .Error}}

{{.Error}}

{{end}} +
+
+{{end}} +
`)) + +func renderSettingsPage(w http.ResponseWriter, status int, data settingsPageData) { + h := w.Header() + h.Set("Content-Type", "text/html; charset=utf-8") + h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "no-referrer") + h.Set("Cache-Control", "no-store") + w.WriteHeader(status) + if err := settingsTemplate.Execute(w, data); err != nil { + slog.Warn("settings page not rendered", "error", err) + } +} + +func (a *API) settingsPage(w http.ResponseWriter, r *http.Request) { + if !a.handoffEnabled() { + http.NotFound(w, r) + return + } + data := settingsPageData{} + if raw := r.URL.Query().Get("code"); raw != "" { + if a.Handoff.Open(raw) { + data.Code = normalizeUserCode(raw) + } else { + data.Error = "This code is unknown or has expired. Start again on the car." + } + } + renderSettingsPage(w, http.StatusOK, data) +} + +// phoneSettingsFromForm checks what the phone sent. Credentials are never put back into the +// page; the numbers are, so a typo is fixed without typing them again. +func phoneSettingsFromForm(form url.Values) (PhoneSettings, error) { + var s PhoneSettings + for _, f := range []struct { + name string + dst *string + }{{"abrpApiKey", &s.AbrpAPIKey}, {"abrpToken", &s.AbrpToken}} { + v := strings.TrimSpace(form.Get(f.name)) + if v == "" { + continue + } + if len(v) < 8 || len(v) > 200 || strings.ContainsAny(v, " \t\r\n") { + return PhoneSettings{}, errors.New("the ABRP API key or user token does not look right") + } + *f.dst = v + } + for _, f := range []struct { + name string + min, max float64 + dst **float64 + label string + }{{"price", 0, 100, &s.PricePerKwh, "charging price"}, {"usable", 10, 150, &s.UsableKwh, "usable capacity"}} { + v := strings.ReplaceAll(strings.TrimSpace(form.Get(f.name)), ",", ".") + if v == "" { + continue + } + n, err := strconv.ParseFloat(v, 64) + if err != nil || n < f.min || n > f.max { + return PhoneSettings{}, errors.New("the " + f.label + " is not a plausible number") + } + *f.dst = &n + } + if s.empty() { + return PhoneSettings{}, errors.New("nothing filled in") + } + return s, nil +} + +func (a *API) settingsSubmit(w http.ResponseWriter, r *http.Request) { + if !a.handoffEnabled() { + http.NotFound(w, r) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 8192) + if err := r.ParseForm(); err != nil { + renderSettingsPage(w, http.StatusBadRequest, settingsPageData{Error: "The form could not be read."}) + return + } + code := normalizeUserCode(r.PostForm.Get("code")) + back := settingsPageData{Code: code, Price: r.PostForm.Get("price"), Usable: r.PostForm.Get("usable")} + switch err := a.Pairing.CheckAdmin(r.PostForm.Get("password"), clientAddress(r)); { + case errors.Is(err, errTooManyFailed): + back.Error = err.Error() + "." + renderSettingsPage(w, http.StatusTooManyRequests, back) + return + case err != nil: + back.Error = "Wrong password." + renderSettingsPage(w, http.StatusUnauthorized, back) + return + } + settings, err := phoneSettingsFromForm(r.PostForm) + if err != nil { + back.Error = strings.ToUpper(err.Error()[:1]) + err.Error()[1:] + "." + renderSettingsPage(w, http.StatusBadRequest, back) + return + } + switch err := a.Handoff.Fill(code, settings); { + case errors.Is(err, errAlreadyFilled): + renderSettingsPage(w, http.StatusConflict, settingsPageData{Error: "These settings were already sent. Start again on the car to send more."}) + return + case err != nil: + renderSettingsPage(w, http.StatusNotFound, settingsPageData{Error: "This code is unknown or has expired. Start again on the car."}) + return + } + slog.Info("settings sent from a phone") + renderSettingsPage(w, http.StatusOK, settingsPageData{Done: true, + Message: "Sent. The car picks the settings up within a few seconds — you can close this page."}) +} diff --git a/server/handoff_test.go b/server/handoff_test.go new file mode 100644 index 0000000..cb94947 --- /dev/null +++ b/server/handoff_test.go @@ -0,0 +1,143 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func startHandoff(t *testing.T, h http.Handler, token string) (deviceCode, userCode string) { + t.Helper() + rec := do(t, h, "POST", "/v1/settings/start", token, `{"installationId":"`+testInstallation+`"}`) + if rec.Code != 201 { + t.Fatalf("start: %d %s", rec.Code, rec.Body) + } + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got["verificationUriComplete"] != "https://car.example.com/settings?code="+got["userCode"].(string) { + t.Fatalf("uri %v", got["verificationUriComplete"]) + } + return got["deviceCode"].(string), got["userCode"].(string) +} + +func sendSettings(t *testing.T, h http.Handler, form url.Values) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest("POST", "/settings", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func TestSettingsTravelFromThePhoneToTheCarOnce(t *testing.T) { + api, _ := newTestAPI() + h := api.Routes() + if rec := do(t, h, "POST", "/v1/settings/start", "", `{"installationId":"`+testInstallation+`"}`); rec.Code != 401 { + t.Fatalf("start without a token: %d", rec.Code) + } + deviceCode, userCode := startHandoff(t, h, testToken) + poll := func() *httptest.ResponseRecorder { + return do(t, h, "POST", "/v1/settings/poll", testToken, `{"deviceCode":"`+deviceCode+`"}`) + } + if rec := poll(); rec.Code != 202 { + t.Fatalf("before the phone: %d", rec.Code) + } + page := do(t, h, "GET", "/settings?code="+userCode, "", "") + if page.Code != 200 || !strings.Contains(page.Body.String(), userCode) { + t.Fatalf("page %d", page.Code) + } + + form := url.Values{"code": {userCode}, "abrpApiKey": {"api-key-1234"}, "abrpToken": {"user-token-5678"}, + "price": {"0,39"}, "password": {"wrong"}} + if rec := sendSettings(t, h, form); rec.Code != 401 || strings.Contains(rec.Body.String(), "user-token-5678") { + t.Fatalf("wrong password: %d (credentials must not be echoed)", rec.Code) + } + form.Set("password", testApprovalPassword) + if rec := sendSettings(t, h, form); rec.Code != 200 { + t.Fatalf("send: %d %s", rec.Code, rec.Body) + } + if rec := sendSettings(t, h, form); rec.Code != 409 { + t.Fatalf("sent twice: %d", rec.Code) + } + + rec := poll() + var got PhoneSettings + if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &got) != nil { + t.Fatalf("collect: %d %s", rec.Code, rec.Body) + } + if got.AbrpAPIKey != "api-key-1234" || got.AbrpToken != "user-token-5678" || got.PricePerKwh == nil || + *got.PricePerKwh != 0.39 || got.UsableKwh != nil { + t.Fatalf("settings %+v", got) + } + if rec.Header().Get("Cache-Control") != "no-store" { + t.Fatal("credentials cacheable") + } + if rec := poll(); rec.Code != 410 { + t.Fatalf("collected twice: %d", rec.Code) + } +} + +func TestSettingsAreOnlyForTheCarThatAsked(t *testing.T) { + api, _ := newTestAPI() + h := api.Routes() + deviceCode, userCode := startHandoff(t, h, testToken) + _ = api.Handoff.Fill(userCode, PhoneSettings{AbrpToken: "user-token-5678"}) + // A car paired as another installation cannot open a request for this one, nor collect it. + other := caller{installationID: "7e2b3c4d-5e6f-4a70-9b8c-1d2e3f4a5b6c"} + if s, err := api.Handoff.Poll(deviceCode, other.mayUse); s != nil || err == nil { + t.Fatal("another car collected the settings") + } + if s, err := api.Handoff.Poll(deviceCode, caller{}.mayUse); s == nil || err != nil { + t.Fatalf("the car itself could not collect: %v", err) + } +} + +func TestPhoneSettingsAreChecked(t *testing.T) { + for name, form := range map[string]url.Values{ + "nothing": {}, + "short token": {"abrpToken": {"abc"}}, + "spaced key": {"abrpApiKey": {"has a space here"}}, + "price absurd": {"price": {"250"}}, + "usable too low": {"usable": {"3"}}, + "not a number": {"price": {"cheap"}}, + } { + if _, err := phoneSettingsFromForm(form); err == nil { + t.Errorf("%s accepted", name) + } + } + s, err := phoneSettingsFromForm(url.Values{"usable": {" 50,8 "}}) + if err != nil || s.UsableKwh == nil || *s.UsableKwh != 50.8 || s.AbrpToken != "" { + t.Fatalf("%+v %v", s, err) + } +} + +func TestHandoffRequestsExpireAndAreReplaced(t *testing.T) { + h := NewHandoff() + first, _ := h.Start(testInstallation) + second, _ := h.Start(testInstallation) + if _, err := h.Poll(first.deviceCode, caller{}.mayUse); err == nil { + t.Fatal("the replaced request is still open") + } + h.Now = func() time.Time { return time.Now().Add(pairingTTL + time.Second) } + if err := h.Fill(second.userCode, PhoneSettings{AbrpToken: "user-token-5678"}); err == nil { + t.Fatal("an expired request took settings") + } +} + +func TestPhoneSettingsNeedPairingSwitchedOn(t *testing.T) { + api, _ := newTestAPI() + api.Pairing = NewPairing("", nil) + h := api.Routes() + if rec := do(t, h, "POST", "/v1/settings/start", testToken, `{"installationId":"`+testInstallation+`"}`); rec.Code != 404 { + t.Fatalf("start: %d", rec.Code) + } + if rec := do(t, h, "GET", "/settings", "", ""); rec.Code != 404 { + t.Fatalf("page: %d", rec.Code) + } +} diff --git a/server/logic_test.go b/server/logic_test.go new file mode 100644 index 0000000..366086e --- /dev/null +++ b/server/logic_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "testing" + "time" +) + +func chunk(date string, atMs int64, km, kwh float64, soh *float64) ConsumptionChunk { + return ConsumptionChunk{ + InstallationID: testInstallation, Date: date, RecordedAt: time.UnixMilli(atMs), + Day: Totals{Km: km, Kwh: kwh, Hours: km / 50, SocDrop: kwh / 0.6}, SohPercent: soh, + } +} + +func ptr(f float64) *float64 { return &f } + +func TestAggregateDaysSumsPerDayNewestFirstWithRunningLifetime(t *testing.T) { + days := aggregateDays([]ConsumptionChunk{ + chunk("2026-10-16", 1000, 10, 2, nil), + chunk("2026-10-17", 3000, 5, 1, nil), + chunk("2026-10-16", 2000, 20, 3, nil), + }) + if len(days) != 2 || days[0].Date != "2026-10-17" || days[1].Date != "2026-10-16" { + t.Fatalf("order: %+v", days) + } + if days[1].Day.Km != 30 || days[1].Day.Kwh != 5 || days[1].RecordedAt.UnixMilli() != 2000 { + t.Fatalf("day sums: %+v", days[1]) + } + if days[1].Lifetime.Km != 30 || days[0].Lifetime.Km != 35 || days[0].Lifetime.Kwh != 6 { + t.Fatalf("lifetime: %+v / %+v", days[0].Lifetime, days[1].Lifetime) + } +} + +func TestAggregateDaysNewestSohWins(t *testing.T) { + days := aggregateDays([]ConsumptionChunk{ + chunk("2026-10-16", 2000, 1, 1, ptr(96)), + chunk("2026-10-16", 1000, 1, 1, ptr(98)), + chunk("2026-10-17", 3000, 1, 1, nil), + }) + if *days[1].SohPercent != 96 { + t.Fatalf("want 96, got %v", *days[1].SohPercent) + } + if days[0].SohPercent != nil { + t.Fatal("a day without SoH got one") + } +} + +func TestLoadConfigFailsClosed(t *testing.T) { + env := map[string]string{ + "INFLUX_URL": "http://influxdb:8086", "INFLUX_TOKEN": "t", "INFLUX_ORG": "mg4", + "INGEST_TOKEN": "0123456789abcdef01234567", + } + get := func(k string) string { return env[k] } + c, err := loadConfig(get) + if err != nil || c.listenAddr != ":8080" || c.influxBucket != "mg4" { + t.Fatalf("defaults: %+v %v", c, err) + } + env["INGEST_TOKEN"] = "short" + if _, err := loadConfig(get); err == nil { + t.Fatal("short ingest token accepted") + } + env["INGEST_TOKEN"] = "0123456789abcdef01234567" + delete(env, "INFLUX_TOKEN") + if _, err := loadConfig(get); err == nil { + t.Fatal("missing InfluxDB token accepted") + } +} diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..521d7a4 --- /dev/null +++ b/server/main.go @@ -0,0 +1,131 @@ +// Command server is the Nils' MG4 car server: the API the car writes its telemetry, +// consumption and charging sessions to, stored in InfluxDB 2. The car sends live data to ABRP +// itself. +// Dashboards are Grafana's job; Grafana reads the same bucket directly. +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" +) + +const ( + minIngestTokenLength = 24 + minApprovalPasswordLength = 12 +) + +type config struct { + listenAddr string + influxURL string + influxToken string + influxOrg string + influxBucket string + ingestToken string + approvalPass string + publicURL string + forgeURL string + forgeRepo string + forgeToken string +} + +// loadConfig reads the environment and fails closed: a missing InfluxDB setting or a +// short ingest token stops the server from starting rather than serving something broken. +func loadConfig(getenv func(string) string) (config, error) { + c := config{ + listenAddr: getenv("LISTEN_ADDR"), + influxURL: getenv("INFLUX_URL"), + influxToken: getenv("INFLUX_TOKEN"), + influxOrg: getenv("INFLUX_ORG"), + influxBucket: getenv("INFLUX_BUCKET"), + ingestToken: getenv("INGEST_TOKEN"), + approvalPass: getenv("APPROVAL_PASSWORD"), + publicURL: getenv("PUBLIC_URL"), + forgeURL: getenv("FORGE_URL"), + forgeRepo: getenv("FORGE_REPO"), + forgeToken: getenv("FORGE_TOKEN"), + } + if c.listenAddr == "" { + c.listenAddr = ":8080" + } + if c.influxBucket == "" { + c.influxBucket = "mg4" + } + for name, value := range map[string]string{"INFLUX_URL": c.influxURL, "INFLUX_TOKEN": c.influxToken, "INFLUX_ORG": c.influxOrg} { + if value == "" { + return config{}, fmt.Errorf("%s is not configured", name) + } + } + if len(c.ingestToken) < minIngestTokenLength { + return config{}, fmt.Errorf("INGEST_TOKEN must be at least %d characters", minIngestTokenLength) + } + // Pairing is optional; when it is on, its password guards token issuance and must not + // be guessable. + // App updates are optional; when the forge is named it must be https, so the token + // never crosses the network in the clear. + if c.forgeURL != "" && !strings.HasPrefix(c.forgeURL, "https://") { + return config{}, errors.New("FORGE_URL must be an https URL") + } + if c.approvalPass != "" && len(c.approvalPass) < minApprovalPasswordLength { + return config{}, fmt.Errorf("APPROVAL_PASSWORD must be at least %d characters", minApprovalPasswordLength) + } + return c, nil +} + +func main() { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil))) + cfg, err := loadConfig(os.Getenv) + if err != nil { + slog.Error("invalid configuration", "error", err) + os.Exit(1) + } + + store := NewInfluxStore(cfg.influxURL, cfg.influxToken, cfg.influxOrg, cfg.influxBucket) + defer store.Close() + // Paired cars' tokens must be known before the first request; starting without them + // would answer every paired car with 401, so a failed load stops the start instead. + loadCtx, cancelLoad := context.WithTimeout(context.Background(), 30*time.Second) + devices, err := store.LoadDevices(loadCtx) + cancelLoad() + if err != nil { + slog.Error("paired devices could not be loaded", "error", err) + os.Exit(1) + } + pairing := NewPairing(cfg.approvalPass, devices) + releases := &Releases{ForgeURL: cfg.forgeURL, Repo: cfg.forgeRepo, Token: cfg.forgeToken} + api := &API{Store: store, IngestToken: cfg.ingestToken, Pairing: pairing, Handoff: NewHandoff(), + Releases: releases, PublicURL: cfg.publicURL} + + srv := &http.Server{ + Addr: cfg.listenAddr, + Handler: api.Routes(), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + go func() { + slog.Info("listening", "addr", cfg.listenAddr, "bucket", cfg.influxBucket, + "pairing", pairing.Enabled(), "pairedDevices", len(devices), "appUpdates", releases.enabled()) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("server failed", "error", err) + os.Exit(1) + } + }() + <-ctx.Done() + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(shutdown); err != nil { + slog.Error("shutdown", "error", err) + } +} diff --git a/server/model.go b/server/model.go new file mode 100644 index 0000000..b121c87 --- /dev/null +++ b/server/model.go @@ -0,0 +1,110 @@ +package main + +import "time" + +// Totals are one day's (or one chunk's) consumption counters. +type Totals struct { + Km float64 `json:"km"` + Kwh float64 `json:"kwh"` + Hours float64 `json:"hours"` + SocDrop float64 `json:"socDrop"` +} + +func (t *Totals) add(o Totals) { + t.Km += o.Km + t.Kwh += o.Kwh + t.Hours += o.Hours + t.SocDrop += o.SocDrop +} + +// ConsumptionChunk is one 2-minute delta of a day's consumption, as the car sends it. +// Days are built from these when history is read. +type ConsumptionChunk struct { + InstallationID string + Date string // the car's local date, YYYY-MM-DD + RecordedAt time.Time + Day Totals + SohPercent *float64 +} + +// DailyConsumption is a day folded from its chunks. +type DailyConsumption struct { + InstallationID string `json:"installationId"` + Date string `json:"date"` + RecordedAt time.Time `json:"recordedAt"` + Day Totals `json:"day"` + SohPercent *float64 `json:"sohPercent,omitempty"` + // Lifetime is the running total up to and including this day; not part of the + // car's history response, which carries one lifetime for all days. + Lifetime Totals `json:"-"` +} + +type CurvePoint struct { + Timestamp time.Time + PowerKw float64 + SocPercent *float64 +} + +type ChargingSession struct { + InstallationID string + SessionID string + StartedAt time.Time + EndedAt time.Time + DurationSeconds float64 + EnergyKwh float64 + ChargingLossPercent float64 + GridEnergyKwh float64 + PricePerKwh float64 + TotalCost float64 + IsDemo bool + StartSocPercent *float64 + EndSocPercent *float64 + Curve []CurvePoint +} + +// telemetryNumbers are the numeric live fields, named and unit-ed as ABRP's tlm/send +// expects them, with the range each one must fall in. +var telemetryNumbers = map[string][2]float64{ + "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}, // kPa + "tire_pressure_rl": {0, 1000}, // kPa + "tire_pressure_rr": {0, 1000}, // kPa +} + +// telemetryFlags are the boolean live fields; ABRP takes them as 0/1. +var telemetryFlags = []string{"is_charging", "is_dcfc", "is_parked"} + +// TelemetrySample is one live sample. Only UTC is always present; an unread signal is +// absent from Numbers and Flags, never 0. +type TelemetrySample struct { + UTC int64 // epoch seconds + Numbers map[string]float64 + Flags map[string]bool +} + +type TelemetryBatch struct { + InstallationID string + Samples []TelemetrySample + // Dropped names the values and samples validation left out, e.g. "samples[3].speed". + Dropped []string +} diff --git a/server/pairing.go b/server/pairing.go new file mode 100644 index 0000000..4b698ee --- /dev/null +++ b/server/pairing.go @@ -0,0 +1,370 @@ +package main + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "errors" + "strings" + "sync" + "time" +) + +// Pairing lets a car get its own ingest token without anyone typing it: the car asks for a +// pairing request and shows its code as a QR code, a phone opens the approval page, the +// owner signs in with the approval password and approves, and the car — polling meanwhile — +// receives a token once. Modelled on the OAuth device authorization grant (RFC 8628). +// +// Pending requests live in memory only: they last ten minutes, and the server runs as one +// replica. Approved devices are persisted (see Device) so their tokens survive a restart. + +const ( + pairingTTL = 10 * time.Minute + pairingInterval = 5 * time.Second + // Pending requests per client address, and in all. A car needs one; a client that opens + // more only crowds out its own, and the total bounds memory. + maxPendingPerClient = 3 + maxPendingPairing = 1000 + // After this many wrong approval passwords from one client address in a window, that + // address waits for the window. Other addresses are not affected, so a stranger guessing + // cannot lock the owner out. The global cap only bounds guessing spread over many + // addresses; it is far above anything a person types. + maxFailedLogins = 5 + maxFailedLoginsGlobal = 100 + failedLoginsWindow = time.Minute + // Unambiguous characters only: no 0/O, 1/I/L, so a code reads right off a car screen. + userCodeAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" +) + +var ( + errUnknownCode = errors.New("unknown or expired code") + errNotPending = errors.New("request already decided") + errTooManyFailed = errors.New("too many wrong passwords, try again in a minute") + errWrongPassword = errors.New("wrong password") + errTooMany = errors.New("too many pending pairing requests") +) + +// Device is a paired car. Only the SHA-256 of its token is kept. +type Device struct { + ID string + InstallationID string + TokenHash string + CreatedAt time.Time + Revoked bool +} + +type pairingState int + +const ( + pairingPending pairingState = iota + // pairingSaving: approved, and the device is being persisted. The car still sees + // pending, so it cannot collect a token whose device might not be saved. + pairingSaving + pairingApproved + pairingDenied +) + +type pairingRequest struct { + deviceCode string + userCode string + installationID string + client string // address that opened the request + expires time.Time + state pairingState + token string // set on approval, handed out once + deviceID string +} + +// Pairing holds pending requests and the known devices. The clock is injectable for tests. +type Pairing struct { + ApprovalPassword string + Now func() time.Time + + mu sync.Mutex + byDevice map[string]*pairingRequest + byUser map[string]*pairingRequest + devices map[string]Device // by token hash + // Wrong passwords: per client address, and all of them together. + failures map[string][]time.Time + globalFailures []time.Time +} + +func NewPairing(password string, devices []Device) *Pairing { + p := &Pairing{ + ApprovalPassword: password, + Now: time.Now, + byDevice: map[string]*pairingRequest{}, + byUser: map[string]*pairingRequest{}, + devices: map[string]Device{}, + failures: map[string][]time.Time{}, + } + for _, d := range devices { + p.devices[d.TokenHash] = d + } + return p +} + +func (p *Pairing) Enabled() bool { return p != nil && p.ApprovalPassword != "" } + +func randomString(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic(err) // crypto/rand does not fail on supported platforms + } + return base64.RawURLEncoding.EncodeToString(b) +} + +func newUserCode() string { + b := make([]byte, 6) + if _, err := rand.Read(b); err != nil { + panic(err) + } + code := make([]byte, 0, 7) + for i, v := range b { + if i == 3 { + code = append(code, '-') + } + // 256 % 31 leaves a slight bias; irrelevant for a 10-minute code that also needs + // the approval password. + code = append(code, userCodeAlphabet[int(v)%len(userCodeAlphabet)]) + } + return string(code) +} + +// normalizeUserCode accepts what a person types: any case, with or without the dash. +func normalizeUserCode(code string) string { + code = strings.ToUpper(strings.TrimSpace(code)) + code = strings.ReplaceAll(code, "-", "") + code = strings.ReplaceAll(code, " ", "") + if len(code) != 6 { + return "" + } + return code[:3] + "-" + code[3:] +} + +func hashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func (p *Pairing) expireLocked(now time.Time) { + for code, r := range p.byDevice { + // A request being saved is finished by Commit or Forget, not expired under them. + if now.After(r.expires) && r.state != pairingSaving { + delete(p.byDevice, code) + delete(p.byUser, r.userCode) + } + } +} + +func (p *Pairing) dropLocked(r *pairingRequest) { + delete(p.byDevice, r.deviceCode) + delete(p.byUser, r.userCode) +} + +// Start opens a pairing request for a car. A new request replaces the same installation's +// undecided one; a client already holding maxPendingPerClient requests gets errTooMany. +func (p *Pairing) Start(installationID, client string) (*pairingRequest, error) { + p.mu.Lock() + defer p.mu.Unlock() + now := p.Now() + p.expireLocked(now) + perClient := 0 + for _, r := range p.byDevice { + if r.state == pairingPending && strings.EqualFold(r.installationID, installationID) { + p.dropLocked(r) + continue + } + if r.client == client { + perClient++ + } + } + if perClient >= maxPendingPerClient || len(p.byDevice) >= maxPendingPairing { + return nil, errTooMany + } + r := &pairingRequest{ + deviceCode: randomString(32), + installationID: installationID, + client: client, + expires: now.Add(pairingTTL), + } + for { + r.userCode = newUserCode() + if _, taken := p.byUser[r.userCode]; !taken { + break + } + } + p.byDevice[r.deviceCode] = r + p.byUser[r.userCode] = r + out := *r + return &out, nil +} + +// Lookup returns the pending request for a user code, for the approval page. +func (p *Pairing) Lookup(userCode string) (pairingRequest, error) { + p.mu.Lock() + defer p.mu.Unlock() + p.expireLocked(p.Now()) + r, ok := p.byUser[normalizeUserCode(userCode)] + if !ok { + return pairingRequest{}, errUnknownCode + } + return *r, nil +} + +func recentOnly(times []time.Time, now time.Time) []time.Time { + recent := times[:0] + for _, t := range times { + if now.Sub(t) < failedLoginsWindow { + recent = append(recent, t) + } + } + return recent +} + +// checkPasswordLocked counts failures per client address and refuses a client while it +// has too many recent ones (or while too many arrive from everywhere at once). +func (p *Pairing) checkPasswordLocked(password, client string, now time.Time) error { + for c, times := range p.failures { + if recent := recentOnly(times, now); len(recent) == 0 { + delete(p.failures, c) + } else { + p.failures[c] = recent + } + } + p.globalFailures = recentOnly(p.globalFailures, now) + if len(p.failures[client]) >= maxFailedLogins || len(p.globalFailures) >= maxFailedLoginsGlobal { + return errTooManyFailed + } + if subtle.ConstantTimeCompare([]byte(password), []byte(p.ApprovalPassword)) != 1 { + p.failures[client] = append(p.failures[client], now) + p.globalFailures = append(p.globalFailures, now) + return errWrongPassword + } + return nil +} + +// Decide approves or denies a pending request. On approval it returns the new device, which +// the caller must persist and then Commit (or Forget, if saving failed): until Commit the +// car keeps seeing pending and the token is not accepted anywhere. +func (p *Pairing) Decide(userCode, password, client string, approve bool) (Device, error) { + p.mu.Lock() + defer p.mu.Unlock() + now := p.Now() + if err := p.checkPasswordLocked(password, client, now); err != nil { + return Device{}, err + } + p.expireLocked(now) + r, ok := p.byUser[normalizeUserCode(userCode)] + if !ok { + return Device{}, errUnknownCode + } + if r.state != pairingPending { + return Device{}, errNotPending + } + if !approve { + r.state = pairingDenied + return Device{}, nil + } + token := randomString(32) // 43 characters + d := Device{ + ID: randomString(9), + InstallationID: r.installationID, + TokenHash: hashToken(token), + CreatedAt: now, + } + r.state = pairingSaving + r.token = token + r.deviceID = d.ID + return d, nil +} + +// Commit finishes an approval once its device is persisted: the token now works, and the +// car collects it at its next poll. +func (p *Pairing) Commit(userCode string, d Device) { + p.mu.Lock() + defer p.mu.Unlock() + p.devices[d.TokenHash] = d + if r, ok := p.byUser[normalizeUserCode(userCode)]; ok && r.deviceID == d.ID { + r.state = pairingApproved + } +} + +// Forget undoes an approval whose device could not be persisted, so no token is handed out +// that would stop working at the next restart. The request is pending again. +func (p *Pairing) Forget(userCode string, d Device) { + p.mu.Lock() + defer p.mu.Unlock() + if r, ok := p.byUser[normalizeUserCode(userCode)]; ok && r.deviceID == d.ID { + r.state = pairingPending + r.token = "" + r.deviceID = "" + } +} + +// Poll is what the car calls: pending, the token (exactly once), denied, or unknown. +func (p *Pairing) Poll(deviceCode string) (state pairingState, token, deviceID string, err error) { + p.mu.Lock() + defer p.mu.Unlock() + p.expireLocked(p.Now()) + r, ok := p.byDevice[deviceCode] + if !ok { + return 0, "", "", errUnknownCode + } + switch r.state { + case pairingSaving: + return pairingPending, "", "", nil + case pairingApproved, pairingDenied: + // Decided: hand the outcome out once, then forget the request. + p.dropLocked(r) + } + return r.state, r.token, r.deviceID, nil +} + +// Authorized returns the paired, unrevoked device a token belongs to. +func (p *Pairing) Authorized(token string) (Device, bool) { + if p == nil || token == "" { + return Device{}, false + } + p.mu.Lock() + defer p.mu.Unlock() + d, ok := p.devices[hashToken(token)] + if !ok || d.Revoked { + return Device{}, false + } + return d, true +} + +func (p *Pairing) Devices() []Device { + p.mu.Lock() + defer p.mu.Unlock() + out := make([]Device, 0, len(p.devices)) + for _, d := range p.devices { + out = append(out, d) + } + return out +} + +// Revoke marks a device revoked and returns it for persisting. +func (p *Pairing) Revoke(id string) (Device, bool) { + p.mu.Lock() + defer p.mu.Unlock() + for hash, d := range p.devices { + if d.ID == id { + d.Revoked = true + p.devices[hash] = d + return d, true + } + } + return Device{}, false +} + +// CheckAdmin verifies the approval password for the device-management API, with the same +// failure counting as the approval page. +func (p *Pairing) CheckAdmin(password, client string) error { + p.mu.Lock() + defer p.mu.Unlock() + return p.checkPasswordLocked(password, client, p.Now()) +} diff --git a/server/pairing_http.go b/server/pairing_http.go new file mode 100644 index 0000000..bece390 --- /dev/null +++ b/server/pairing_http.go @@ -0,0 +1,318 @@ +package main + +import ( + "errors" + "html/template" + "io" + "log/slog" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +// HTTP side of pairing. +// +// POST /v1/pair/start car: open a request {installationId} → codes and URL +// POST /v1/pair/poll car: collect the outcome {deviceCode} → 202 / 200 token / 403 / 410 +// GET /pair[?code=XXX-XXX] phone: approval page +// POST /pair phone: approve or deny (needs the approval password) +// GET /v1/devices list paired cars Bearer +// POST /v1/devices/{id}/revoke revoke a car's token Bearer +// +// Start and poll need no token — the car does not have one yet. What they hand out is +// worthless until a person who knows the approval password approves it. + +func (a *API) pairingRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /v1/pair/start", a.pairStart) + mux.HandleFunc("POST /v1/pair/poll", a.pairPoll) + mux.HandleFunc("GET /pair", a.pairPage) + mux.HandleFunc("POST /pair", a.pairDecide) + mux.HandleFunc("GET /v1/devices", a.listDevices) + mux.HandleFunc("POST /v1/devices/{id}/revoke", a.revokeDevice) +} + +func (a *API) pairingDisabled(w http.ResponseWriter) bool { + if a.Pairing.Enabled() { + return false + } + writeJSON(w, http.StatusNotFound, map[string]string{"error": "pairing is not enabled"}) + return true +} + +func readSmallJSON(r *http.Request) (object, error) { + body, err := io.ReadAll(io.LimitReader(r.Body, 4097)) + if err != nil || len(body) > 4096 { + return nil, invalid("body is too large") + } + return decodeObject(body) +} + +// publicURL is where the phone should go: PUBLIC_URL when set, otherwise what the request +// came in on (behind Traefik, the forwarded scheme and host). +func (a *API) publicURL(r *http.Request) string { + if a.PublicURL != "" { + return strings.TrimRight(a.PublicURL, "/") + } + scheme := "http" + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } + host := r.Header.Get("X-Forwarded-Host") + if host == "" { + host = r.Host + } + return scheme + "://" + host +} + +func (a *API) pairStart(w http.ResponseWriter, r *http.Request) { + if a.pairingDisabled(w) { + return + } + o, err := readSmallJSON(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + id, err := validUUID(o["installationId"], "installationId") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + req, err := a.Pairing.Start(id, clientAddress(r)) + if err != nil { + writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": err.Error()}) + return + } + base := a.publicURL(r) + "/pair" + writeJSON(w, http.StatusCreated, map[string]any{ + "deviceCode": req.deviceCode, + "userCode": req.userCode, + "verificationUri": base, + "verificationUriComplete": base + "?code=" + url.QueryEscape(req.userCode), + "expiresIn": int(pairingTTL / time.Second), + "interval": int(pairingInterval / time.Second), + }) +} + +func (a *API) pairPoll(w http.ResponseWriter, r *http.Request) { + if a.pairingDisabled(w) { + return + } + o, err := readSmallJSON(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + code, _ := o["deviceCode"].(string) + state, token, deviceID, err := a.Pairing.Poll(code) + switch { + case err != nil: + writeJSON(w, http.StatusGone, map[string]string{"error": "expired_token"}) + case state == pairingPending: + writeJSON(w, http.StatusAccepted, map[string]string{"status": "authorization_pending"}) + case state == pairingDenied: + writeJSON(w, http.StatusForbidden, map[string]string{"error": "access_denied"}) + default: + writeJSON(w, http.StatusOK, map[string]string{"token": token, "deviceId": deviceID}) + } +} + +// clientAddress is who is asking, for the per-client limits: the last X-Forwarded-For entry, +// which is the one Traefik adds (anything left of it comes from the client and can be +// made up), or the connection's own address without a proxy. +func clientAddress(r *http.Request) string { + if forwarded := r.Header.Values("X-Forwarded-For"); len(forwarded) > 0 { + last := forwarded[len(forwarded)-1] + if i := strings.LastIndex(last, ","); i >= 0 { + last = last[i+1:] + } + if addr := strings.TrimSpace(last); addr != "" { + return addr + } + } + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return host + } + return r.RemoteAddr +} + +// ---------- Approval page ---------- + +type pageData struct { + Code string + Message string + Error string + Done bool +} + +// pageStyle is shared by the phone pages (pairing, settings): dark, MG red, no scripts. +const pageStyle = ` +:root{color-scheme:dark} +body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#000;color:#F4F3EF;font:16px/1.5 system-ui,sans-serif} +main{width:min(92vw,420px);background:#121212;border:1px solid #363B3D;border-radius:16px;padding:28px} +h1{margin:0 0 4px;font-size:22px}p{color:#BDC4C6;margin:8px 0 18px} +.code{font:600 34px/1.2 ui-monospace,monospace;letter-spacing:4px;color:#FF9A9E;text-align:center;margin:18px 0} +label{display:block;font-size:14px;color:#BDC4C6;margin:14px 0 6px} +input{box-sizing:border-box;width:100%;padding:14px;border-radius:10px;border:1px solid #363B3D;background:#000;color:#F4F3EF;font-size:18px} +.row{display:flex;gap:10px;margin-top:20px} +button{flex:1;padding:14px;border-radius:10px;border:0;font-size:17px;font-weight:600;cursor:pointer} +.approve{background:#E0424C;color:#1A0B0C}.deny{background:#2A1416;color:#FF9A9E;border:1px solid #3A1215} +.error{color:#FFB4AB}.ok{color:#A4DCC1} +` + +var pairTemplate = template.Must(template.New("pair").Parse(` + +Pair a car — Nils' MG4 +
+

Pair a car

+{{if .Done}} +

{{.Message}}

+{{else}} +

Check that this code matches the one on the car's screen, then approve with the approval password.

+
+{{if .Code}}
{{.Code}}
+{{else}} +{{end}} + + + +{{if .Error}}

{{.Error}}

{{end}} +
+
+
+{{end}} +
`)) + +func renderPage(w http.ResponseWriter, status int, data pageData) { + h := w.Header() + h.Set("Content-Type", "text/html; charset=utf-8") + // No scripts at all, no framing, forms only back to this page. + h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "no-referrer") + h.Set("Cache-Control", "no-store") + w.WriteHeader(status) + if err := pairTemplate.Execute(w, data); err != nil { + slog.Warn("pair page not rendered", "error", err) + } +} + +func (a *API) pairPage(w http.ResponseWriter, r *http.Request) { + if !a.Pairing.Enabled() { + http.NotFound(w, r) + return + } + data := pageData{} + if raw := r.URL.Query().Get("code"); raw != "" { + if req, err := a.Pairing.Lookup(raw); err == nil && req.state == pairingPending { + data.Code = req.userCode + } else { + data.Error = "This code is unknown or has expired. Start pairing again on the car." + } + } + renderPage(w, http.StatusOK, data) +} + +func (a *API) pairDecide(w http.ResponseWriter, r *http.Request) { + if !a.Pairing.Enabled() { + http.NotFound(w, r) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 4096) + if err := r.ParseForm(); err != nil { + renderPage(w, http.StatusBadRequest, pageData{Error: "The form could not be read."}) + return + } + code := normalizeUserCode(r.PostForm.Get("code")) + approve := r.PostForm.Get("action") != "deny" + device, err := a.Pairing.Decide(code, r.PostForm.Get("password"), clientAddress(r), approve) + switch { + case errors.Is(err, errWrongPassword): + renderPage(w, http.StatusUnauthorized, pageData{Code: code, Error: "Wrong password."}) + return + case errors.Is(err, errTooManyFailed): + renderPage(w, http.StatusTooManyRequests, pageData{Code: code, Error: err.Error() + "."}) + return + case errors.Is(err, errUnknownCode), errors.Is(err, errNotPending): + renderPage(w, http.StatusNotFound, pageData{Error: "This code is unknown, expired or already used. Start pairing again on the car."}) + return + case err != nil: + renderPage(w, http.StatusInternalServerError, pageData{Code: code, Error: "Something went wrong."}) + return + } + if !approve { + renderPage(w, http.StatusOK, pageData{Done: true, Message: "Denied. The car was not paired."}) + return + } + // Persist before the car can collect the token: a token that vanished at the next + // restart would look like a pairing that worked. + if err := a.Store.SaveDevice(r.Context(), device); err != nil { + a.Pairing.Forget(code, device) + slog.Error("device not saved", "error", err) + renderPage(w, http.StatusInternalServerError, pageData{Code: code, Error: "The car could not be saved. Try again."}) + return + } + a.Pairing.Commit(code, device) + slog.Info("car paired", "device", device.ID, "installation", device.InstallationID) + renderPage(w, http.StatusOK, pageData{Done: true, Message: "Approved. The car finishes pairing on its own within a few seconds — you can close this page."}) +} + +// ---------- Device management ---------- + +func (a *API) adminAuthorized(w http.ResponseWriter, r *http.Request) bool { + if a.pairingDisabled(w) { + return false + } + supplied, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + if !ok { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return false + } + switch err := a.Pairing.CheckAdmin(supplied, clientAddress(r)); { + case errors.Is(err, errTooManyFailed): + writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": err.Error()}) + return false + case err != nil: + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return false + } + return true +} + +type deviceJSON struct { + ID string `json:"id"` + InstallationID string `json:"installationId"` + CreatedAt time.Time `json:"createdAt"` + Revoked bool `json:"revoked"` +} + +func (a *API) listDevices(w http.ResponseWriter, r *http.Request) { + if !a.adminAuthorized(w, r) { + return + } + out := []deviceJSON{} + for _, d := range a.Pairing.Devices() { + out = append(out, deviceJSON{d.ID, d.InstallationID, d.CreatedAt, d.Revoked}) + } + writeJSON(w, http.StatusOK, map[string]any{"devices": out}) +} + +func (a *API) revokeDevice(w http.ResponseWriter, r *http.Request) { + if !a.adminAuthorized(w, r) { + return + } + d, ok := a.Pairing.Revoke(r.PathValue("id")) + if !ok { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such device"}) + return + } + if err := a.Store.SaveDevice(r.Context(), d); err != nil { + slog.Error("revocation not saved", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "revoked until restart only; saving failed"}) + return + } + slog.Info("car revoked", "device", d.ID) + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/pairing_test.go b/server/pairing_test.go new file mode 100644 index 0000000..2be5f00 --- /dev/null +++ b/server/pairing_test.go @@ -0,0 +1,331 @@ +package main + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "testing" + "time" +) + +func startPairing(t *testing.T, h http.Handler) (deviceCode, userCode, uriComplete string) { + t.Helper() + rec := do(t, h, "POST", "/v1/pair/start", "", `{"installationId":"`+testInstallation+`"}`) + if rec.Code != 201 { + t.Fatalf("start: %d %s", rec.Code, rec.Body) + } + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + return got["deviceCode"].(string), got["userCode"].(string), got["verificationUriComplete"].(string) +} + +func poll(t *testing.T, h http.Handler, deviceCode string) (int, map[string]string) { + t.Helper() + rec := do(t, h, "POST", "/v1/pair/poll", "", `{"deviceCode":"`+deviceCode+`"}`) + var got map[string]string + _ = json.Unmarshal(rec.Body.Bytes(), &got) + return rec.Code, got +} + +func submit(t *testing.T, h http.Handler, code, password, action string) *httptest.ResponseRecorder { + t.Helper() + return submitFrom(t, h, "", code, password, action) +} + +// submitFrom submits the approval form as if from forwardedFor, the address Traefik adds. +func submitFrom(t *testing.T, h http.Handler, forwardedFor, code, password, action string) *httptest.ResponseRecorder { + t.Helper() + form := url.Values{"code": {code}, "password": {password}, "action": {action}} + req := httptest.NewRequest("POST", "/pair", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if forwardedFor != "" { + req.Header.Set("X-Forwarded-For", forwardedFor) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func TestPairingHappyPath(t *testing.T) { + api, store := newTestAPI() + h := api.Routes() + deviceCode, userCode, uri := startPairing(t, h) + if !regexpUserCode.MatchString(userCode) { + t.Fatalf("user code %q", userCode) + } + if uri != "https://car.example.com/pair?code="+userCode { + t.Fatalf("verification URI %q", uri) + } + if status, _ := poll(t, h, deviceCode); status != 202 { + t.Fatalf("before approval: want 202, got %d", status) + } + + // The phone opens the link: the page shows the code and asks for the password. + page := do(t, h, "GET", "/pair?code="+strings.ToLower(strings.ReplaceAll(userCode, "-", "")), "", "") + if page.Code != 200 || !strings.Contains(page.Body.String(), userCode) { + t.Fatalf("page: %d", page.Code) + } + if csp := page.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "frame-ancestors 'none'") { + t.Fatalf("CSP %q", csp) + } + + if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Approved") { + t.Fatalf("approve: %d %s", rec.Code, rec.Body) + } + if len(store.devices) != 1 || store.devices[0].InstallationID != testInstallation { + t.Fatalf("device not persisted: %+v", store.devices) + } + status, got := poll(t, h, deviceCode) + if status != 200 || len(got["token"]) < minIngestTokenLength || got["deviceId"] == "" { + t.Fatalf("after approval: %d %v", status, got) + } + // Only the hash is stored, never the token. + if store.devices[0].TokenHash == got["token"] || store.devices[0].TokenHash != hashToken(got["token"]) { + t.Fatal("stored token hash is wrong") + } + // The token is handed out once. + if status, _ := poll(t, h, deviceCode); status != 410 { + t.Fatalf("second collection: want 410, got %d", status) + } + // And it works on the ingest endpoints. + rec := do(t, h, "POST", "/v1/telemetry", got["token"], string(telemetryBody(`{"utc":1790000000,"soc":80}`))) + if rec.Code != 200 { + t.Fatalf("paired token on telemetry: %d %s", rec.Code, rec.Body) + } +} + +func TestPairingWrongPasswordAndLockout(t *testing.T) { + api, store := newTestAPI() + h := api.Routes() + deviceCode, userCode, _ := startPairing(t, h) + for i := 0; i < maxFailedLogins; i++ { + if rec := submit(t, h, userCode, "wrong", "approve"); rec.Code != 401 { + t.Fatalf("attempt %d: want 401, got %d", i, rec.Code) + } + } + // Locked now — even the right password waits. + if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 429 { + t.Fatalf("locked: want 429, got %d", rec.Code) + } + if status, _ := poll(t, h, deviceCode); status != 202 || len(store.devices) != 0 { + t.Fatal("a locked-out attempt changed the request") + } + // The lockout is per address: someone guessing elsewhere does not lock the owner out. + if rec := submitFrom(t, h, "198.51.100.7", userCode, testApprovalPassword, "deny"); rec.Code != 200 { + t.Fatalf("other address locked out too: %d", rec.Code) + } + deviceCode, userCode, _ = startPairing(t, h) + // After the window the right password works again. + api.Pairing.Now = func() time.Time { return time.Now().Add(failedLoginsWindow + time.Second) } + if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 200 { + t.Fatalf("after the window: want 200, got %d %s", rec.Code, rec.Body) + } +} + +func TestPairingDenyAndExpiry(t *testing.T) { + api, _ := newTestAPI() + h := api.Routes() + deviceCode, userCode, _ := startPairing(t, h) + if rec := submit(t, h, userCode, testApprovalPassword, "deny"); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Denied") { + t.Fatalf("deny: %d", rec.Code) + } + if status, _ := poll(t, h, deviceCode); status != 403 { + t.Fatalf("denied: want 403, got %d", status) + } + if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 404 { + t.Fatalf("a decided code was approvable: %d", rec.Code) + } + + deviceCode, userCode, _ = startPairing(t, h) + api.Pairing.Now = func() time.Time { return time.Now().Add(pairingTTL + time.Second) } + if status, _ := poll(t, h, deviceCode); status != 410 { + t.Fatalf("expired: want 410, got %d", status) + } + if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 404 { + t.Fatalf("an expired code was approvable: %d", rec.Code) + } +} + +func TestPairingNotPersistedMeansNoToken(t *testing.T) { + api, store := newTestAPI() + h := api.Routes() + deviceCode, userCode, _ := startPairing(t, h) + store.fail = errors.New("influx down") + if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 500 { + t.Fatalf("want 500, got %d", rec.Code) + } + if status, _ := poll(t, h, deviceCode); status != 202 { + t.Fatalf("a token was handed out that was never saved: %d", status) + } + store.fail = nil + if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 200 { + t.Fatalf("retry: %d", rec.Code) + } + if status, _ := poll(t, h, deviceCode); status != 200 { + t.Fatalf("after retry: %d", status) + } +} + +func TestRevokedTokenIsRejected(t *testing.T) { + api, store := newTestAPI() + h := api.Routes() + deviceCode, userCode, _ := startPairing(t, h) + submit(t, h, userCode, testApprovalPassword, "approve") + _, got := poll(t, h, deviceCode) + token := got["token"] + + if rec := do(t, h, "GET", "/v1/devices", "wrong-password", ""); rec.Code != 401 { + t.Fatalf("devices without the password: %d", rec.Code) + } + if rec := do(t, h, "GET", "/v1/devices", token, ""); rec.Code != 401 { + t.Fatal("a car token opened the device list") + } + rec := do(t, h, "GET", "/v1/devices", testApprovalPassword, "") + if rec.Code != 200 || !strings.Contains(rec.Body.String(), got["deviceId"]) || strings.Contains(rec.Body.String(), store.devices[0].TokenHash) { + t.Fatalf("list: %d %s", rec.Code, rec.Body) + } + if rec := do(t, h, "POST", "/v1/devices/"+got["deviceId"]+"/revoke", testApprovalPassword, ""); rec.Code != 204 { + t.Fatalf("revoke: %d", rec.Code) + } + if last := store.devices[len(store.devices)-1]; !last.Revoked { + t.Fatal("revocation not persisted") + } + if rec := do(t, h, "POST", "/v1/telemetry", token, string(telemetryBody(`{"utc":1790000000}`))); rec.Code != 401 { + t.Fatalf("revoked token accepted: %d", rec.Code) + } + // The shared ingest token is unaffected. + if rec := do(t, h, "POST", "/v1/telemetry", testToken, string(telemetryBody(`{"utc":1790000000,"soc":1}`))); rec.Code != 200 { + t.Fatalf("shared token: %d", rec.Code) + } +} + +func TestPairedDevicesSurviveARestart(t *testing.T) { + devices := []Device{{ID: "d1", TokenHash: hashToken("restored-token-0123456789abcdef"), CreatedAt: time.Now()}, + {ID: "d2", TokenHash: hashToken("revoked-token-0123456789abcdef"), CreatedAt: time.Now(), Revoked: true}} + p := NewPairing(testApprovalPassword, devices) + _, restored := p.Authorized("restored-token-0123456789abcdef") + _, revoked := p.Authorized("revoked-token-0123456789abcdef") + if !restored || revoked { + t.Fatal("restored devices not honoured") + } +} + +func TestPairingOffWithoutAPassword(t *testing.T) { + api, _ := newTestAPI() + api.Pairing = NewPairing("", nil) + h := api.Routes() + if rec := do(t, h, "POST", "/v1/pair/start", "", `{"installationId":"`+testInstallation+`"}`); rec.Code != 404 { + t.Fatalf("start: %d", rec.Code) + } + if rec := do(t, h, "GET", "/pair", "", ""); rec.Code != 404 { + t.Fatalf("page: %d", rec.Code) + } + if rec := do(t, h, "GET", "/v1/devices", "", ""); rec.Code != 404 { + t.Fatalf("devices: %d", rec.Code) + } +} + +func TestUserCodesNormalise(t *testing.T) { + for in, want := range map[string]string{"k7f-3qx": "K7F-3QX", " K7F3QX ": "K7F-3QX", "k7f 3qx": "K7F-3QX", "K7F-3Q": "", "": ""} { + if got := normalizeUserCode(in); got != want { + t.Errorf("%q: got %q, want %q", in, got, want) + } + } +} + +func TestApprovalPasswordMustBeLongEnough(t *testing.T) { + env := map[string]string{"INFLUX_URL": "u", "INFLUX_TOKEN": "t", "INFLUX_ORG": "o", + "INGEST_TOKEN": "0123456789abcdef01234567", "APPROVAL_PASSWORD": "short"} + if _, err := loadConfig(func(k string) string { return env[k] }); err == nil { + t.Fatal("short approval password accepted") + } +} + +var regexpUserCode = regexp.MustCompile(`^[` + userCodeAlphabet + `]{3}-[` + userCodeAlphabet + `]{3}$`) + +func TestPairedTokenOnlyServesItsOwnInstallation(t *testing.T) { + api, _ := newTestAPI() + h := api.Routes() + deviceCode, userCode, _ := startPairing(t, h) + submit(t, h, userCode, testApprovalPassword, "approve") + _, got := poll(t, h, deviceCode) + token := got["token"] + other := "7e2b3c4d-5e6f-4a70-9b8c-1d2e3f4a5b6c" + body := `{"installationId":"` + other + `","samples":[{"utc":1790000000,"soc":5}]}` + if rec := do(t, h, "POST", "/v1/telemetry", token, body); rec.Code != 403 { + t.Fatalf("telemetry for another car: %d", rec.Code) + } + if rec := do(t, h, "GET", "/v1/consumption/history/"+other, token, ""); rec.Code != 403 { + t.Fatalf("another car's history: %d", rec.Code) + } + if rec := do(t, h, "GET", "/v1/consumption/history/"+testInstallation, token, ""); rec.Code != 200 { + t.Fatalf("own history: %d", rec.Code) + } + // The shared token serves any installation. + if rec := do(t, h, "POST", "/v1/telemetry", testToken, body); rec.Code != 200 { + t.Fatalf("shared token: %d", rec.Code) + } +} + +func TestATokenIsCollectableOnlyOnceSaved(t *testing.T) { + p := NewPairing(testApprovalPassword, nil) + r, err := p.Start(testInstallation, "192.0.2.1") + if err != nil { + t.Fatal(err) + } + d, err := p.Decide(r.userCode, testApprovalPassword, "192.0.2.1", true) + if err != nil { + t.Fatal(err) + } + // Saving is in flight: the car still waits, and the token is not accepted yet. + if state, token, _, _ := p.Poll(r.deviceCode); state != pairingPending || token != "" { + t.Fatalf("collectable while saving: %v %q", state, token) + } + p.Forget(r.userCode, d) + if _, err := p.Decide(r.userCode, testApprovalPassword, "192.0.2.1", true); err != nil { + t.Fatalf("not approvable again after a failed save: %v", err) + } +} + +func TestPairingStartsAreLimitedPerAddress(t *testing.T) { + p := NewPairing(testApprovalPassword, nil) + ids := []string{"6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a51", "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a52", + "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a53", "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a54"} + for _, id := range ids[:maxPendingPerClient] { + if _, err := p.Start(id, "203.0.113.9"); err != nil { + t.Fatal(err) + } + } + if _, err := p.Start(ids[3], "203.0.113.9"); !errors.Is(err, errTooMany) { + t.Fatalf("over the per-address limit: %v", err) + } + // The car on its own address is unaffected, and starting again replaces its request. + first, err := p.Start(testInstallation, "192.0.2.1") + if err != nil { + t.Fatal(err) + } + if _, err := p.Start(testInstallation, "192.0.2.1"); err != nil { + t.Fatal(err) + } + if _, _, _, err := p.Poll(first.deviceCode); !errors.Is(err, errUnknownCode) { + t.Fatal("the replaced request is still open") + } +} + +func TestClientAddressTakesTheProxysEntry(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("X-Forwarded-For", "10.9.9.9, 198.51.100.7") + if got := clientAddress(req); got != "198.51.100.7" { + t.Fatalf("got %q", got) + } + req.Header.Del("X-Forwarded-For") + if got := clientAddress(req); got != "192.0.2.1" { + t.Fatalf("without a proxy: %q", got) + } +} diff --git a/server/releases.go b/server/releases.go new file mode 100644 index 0000000..ce3d356 --- /dev/null +++ b/server/releases.go @@ -0,0 +1,209 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// Releases relays the app's own releases from the private forge repository to the car, for +// the in-app updater. The repository stays private and the forge token stays here: the car +// asks this server with its own token and never holds a forge credential. +// +// GET /v1/app/releases the repo's releases, GitHub-shaped +// GET /v1/app/assets/{release}/{asset}/{name} one attachment, streamed through +// +// The car still checks everything itself (SHA-256 sidecar, package name, signature by the +// installer); this only carries bytes. Off unless FORGE_URL, FORGE_REPO and FORGE_TOKEN are set. +type Releases struct { + ForgeURL string // e.g. https://git.stinnesbeck.com + Repo string // owner/name + Token string // read-only on the repository + Client *http.Client +} + +const ( + maxReleaseListBytes = 1 << 20 + maxAssetBytes = 64 << 20 + // An APK over a slow mobile link takes longer than the server's general write timeout. + assetWriteTimeout = 10 * time.Minute +) + +func (rel *Releases) enabled() bool { + return rel != nil && rel.ForgeURL != "" && rel.Repo != "" && rel.Token != "" +} + +func (rel *Releases) client() *http.Client { + if rel.Client != nil { + return rel.Client + } + return &http.Client{Timeout: 5 * time.Minute, CheckRedirect: rel.sameHostOnly} +} + +// sameHostOnly keeps the token on the forge: a redirect elsewhere is refused, not followed +// with the Authorization header. +func (rel *Releases) sameHostOnly(req *http.Request, via []*http.Request) error { + forge, err := url.Parse(rel.ForgeURL) + if err != nil || req.URL.Host != forge.Host || req.URL.Scheme != forge.Scheme { + return errors.New("redirect away from the forge refused") + } + if len(via) > 5 { + return errors.New("too many redirects") + } + return nil +} + +func (rel *Releases) get(ctx context.Context, path string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(rel.ForgeURL, "/")+path, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "token "+rel.Token) + req.Header.Set("Accept", "application/json") + return rel.client().Do(req) +} + +type forgeAsset struct { + ID int64 `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` +} + +type forgeRelease struct { + ID int64 `json:"id"` + TagName string `json:"tag_name"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` + Assets []forgeAsset `json:"assets"` +} + +// carRelease is what the car's updater reads: GitHub's field names, with download URLs that +// point back at this server. +type carRelease struct { + TagName string `json:"tag_name"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` + Assets []carAsset `json:"assets"` +} + +type carAsset struct { + Name string `json:"name"` + Size int64 `json:"size"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +func (a *API) releaseRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /v1/app/releases", a.ingest(a.appReleases)) + mux.HandleFunc("GET /v1/app/assets/{release}/{asset}/{name}", a.ingest(a.appAsset)) +} + +func (a *API) releasesOff(w http.ResponseWriter) bool { + if a.Releases.enabled() { + return false + } + writeJSON(w, http.StatusNotFound, map[string]string{"error": "app updates are not enabled"}) + return true +} + +func (a *API) appReleases(w http.ResponseWriter, r *http.Request, _ caller, _ []byte) error { + if a.releasesOff(w) { + return nil + } + resp, err := a.Releases.get(r.Context(), "/api/v1/repos/"+a.Releases.Repo+"/releases?limit=20") + if err != nil { + return fmt.Errorf("forge releases: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + slog.Warn("forge refused the release list", "status", resp.StatusCode) + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "the forge did not answer"}) + return nil + } + var releases []forgeRelease + if err := json.NewDecoder(io.LimitReader(resp.Body, maxReleaseListBytes)).Decode(&releases); err != nil { + return fmt.Errorf("forge releases: %w", err) + } + base := a.publicURL(r) + "/v1/app/assets/" + out := make([]carRelease, 0, len(releases)) + for _, rel := range releases { + cr := carRelease{TagName: rel.TagName, Prerelease: rel.Prerelease, Draft: rel.Draft, Assets: []carAsset{}} + for _, as := range rel.Assets { + cr.Assets = append(cr.Assets, carAsset{Name: as.Name, Size: as.Size, + BrowserDownloadURL: base + strconv.FormatInt(rel.ID, 10) + "/" + strconv.FormatInt(as.ID, 10) + + "/" + url.PathEscape(as.Name)}) + } + out = append(out, cr) + } + writeJSON(w, http.StatusOK, out) + return nil +} + +// appAsset streams one release attachment. The forge's own metadata names the file, so a car +// cannot ask for anything but an attachment of this repository's releases. +func (a *API) appAsset(w http.ResponseWriter, r *http.Request, _ caller, _ []byte) error { + if a.releasesOff(w) { + return nil + } + releaseID, err1 := strconv.ParseInt(r.PathValue("release"), 10, 64) + assetID, err2 := strconv.ParseInt(r.PathValue("asset"), 10, 64) + if err1 != nil || err2 != nil || releaseID <= 0 || assetID <= 0 { + return invalid("release or asset is invalid") + } + meta, err := a.Releases.get(r.Context(), fmt.Sprintf("/api/v1/repos/%s/releases/%d/assets/%d", + a.Releases.Repo, releaseID, assetID)) + if err != nil { + return fmt.Errorf("forge asset: %w", err) + } + var asset struct { + Name string `json:"name"` + UUID string `json:"uuid"` + } + decodeErr := json.NewDecoder(io.LimitReader(meta.Body, 64<<10)).Decode(&asset) + meta.Body.Close() + if meta.StatusCode == http.StatusNotFound { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such asset"}) + return nil + } + if meta.StatusCode != http.StatusOK || decodeErr != nil || asset.UUID == "" { + slog.Warn("forge refused the asset", "status", meta.StatusCode) + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "the forge did not answer"}) + return nil + } + if asset.Name != r.PathValue("name") { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such asset"}) + return nil + } + file, err := a.Releases.get(r.Context(), "/attachments/"+url.PathEscape(asset.UUID)) + if err != nil { + return fmt.Errorf("forge download: %w", err) + } + defer file.Body.Close() + if file.StatusCode != http.StatusOK { + slog.Warn("forge refused the download", "status", file.StatusCode) + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "the forge did not answer"}) + return nil + } + if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(assetWriteTimeout)); err != nil { + slog.Warn("asset write deadline not extended", "error", err) + } + h := w.Header() + h.Set("Content-Type", "application/octet-stream") + h.Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(asset.Name, `"`, "")+`"`) + h.Set("Cache-Control", "no-store") + if file.ContentLength > 0 && file.ContentLength <= maxAssetBytes { + h.Set("Content-Length", strconv.FormatInt(file.ContentLength, 10)) + } + w.WriteHeader(http.StatusOK) + if _, err := io.Copy(w, io.LimitReader(file.Body, maxAssetBytes)); err != nil { + slog.Warn("asset transfer cut short", "asset", asset.Name, "error", err) + } + return nil +} diff --git a/server/releases_test.go b/server/releases_test.go new file mode 100644 index 0000000..4c0c7e0 --- /dev/null +++ b/server/releases_test.go @@ -0,0 +1,115 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +const testForgeToken = "forge-read-token-0123456789" + +// fakeForge answers like Gitea for one pre-release with an APK and its sidecar, and only to +// the right token. +func fakeForge(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + auth := func(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "token "+testForgeToken { + http.NotFound(w, r) // a private repository looks absent without a login + return + } + h(w, r) + } + } + mux.HandleFunc("GET /api/v1/repos/nils/mg4/releases", auth(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`[{"id":7,"tag_name":"car-2.16.0","prerelease":true,"draft":false,"assets":[ + {"id":11,"name":"NilsMG4-unstable-2.16.0.apk","size":4}, + {"id":12,"name":"NilsMG4-unstable-2.16.0.apk.sha256","size":64}]}]`)) + })) + mux.HandleFunc("GET /api/v1/repos/nils/mg4/releases/7/assets/11", auth(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"id":11,"name":"NilsMG4-unstable-2.16.0.apk","uuid":"abc-uuid"}`)) + })) + mux.HandleFunc("GET /attachments/abc-uuid", auth(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("APK!")) + })) + return httptest.NewTLSServer(mux) +} + +func releasesAPI(t *testing.T, forge *httptest.Server) http.Handler { + t.Helper() + api, _ := newTestAPI() + api.Releases = &Releases{ForgeURL: forge.URL, Repo: "nils/mg4", Token: testForgeToken, Client: forge.Client()} + return api.Routes() +} + +func TestReleasesAreRelayedWithDownloadsThroughTheServer(t *testing.T) { + forge := fakeForge(t) + defer forge.Close() + h := releasesAPI(t, forge) + if rec := do(t, h, "GET", "/v1/app/releases", "", ""); rec.Code != 401 { + t.Fatalf("without the car's token: %d", rec.Code) + } + rec := do(t, h, "GET", "/v1/app/releases", testToken, "") + if rec.Code != 200 { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + var got []carRelease + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if len(got) != 1 || !got[0].Prerelease || len(got[0].Assets) != 2 || + got[0].Assets[0].BrowserDownloadURL != "https://car.example.com/v1/app/assets/7/11/NilsMG4-unstable-2.16.0.apk" { + t.Fatalf("releases %+v", got) + } + if strings.Contains(rec.Body.String(), testForgeToken) || strings.Contains(rec.Body.String(), forge.URL) { + t.Fatal("the forge or its token leaked to the car") + } + file := do(t, h, "GET", "/v1/app/assets/7/11/NilsMG4-unstable-2.16.0.apk", testToken, "") + if file.Code != 200 || file.Body.String() != "APK!" { + t.Fatalf("download %d %q", file.Code, file.Body) + } + // The name must be the asset's own: no asking for one file under another's name. + if rec := do(t, h, "GET", "/v1/app/assets/7/11/other.apk", testToken, ""); rec.Code != 404 { + t.Fatalf("wrong name: %d", rec.Code) + } +} + +func TestAWrongForgeTokenIsABadGatewayNotALeak(t *testing.T) { + forge := fakeForge(t) + defer forge.Close() + api, _ := newTestAPI() + api.Releases = &Releases{ForgeURL: forge.URL, Repo: "nils/mg4", Token: "wrong", Client: forge.Client()} + if rec := do(t, api.Routes(), "GET", "/v1/app/releases", testToken, ""); rec.Code != 502 { + t.Fatalf("%d", rec.Code) + } +} + +func TestAppUpdatesAreOffWithoutAForge(t *testing.T) { + api, _ := newTestAPI() + if rec := do(t, api.Routes(), "GET", "/v1/app/releases", testToken, ""); rec.Code != 404 { + t.Fatalf("%d", rec.Code) + } +} + +func TestRedirectsAwayFromTheForgeAreRefused(t *testing.T) { + rel := &Releases{ForgeURL: "https://git.example.com"} + away, _ := http.NewRequest("GET", "https://elsewhere.example.com/x", nil) + if rel.sameHostOnly(away, nil) == nil { + t.Fatal("redirect to another host followed") + } + same, _ := http.NewRequest("GET", "https://git.example.com/attachments/x", nil) + if err := rel.sameHostOnly(same, nil); err != nil { + t.Fatal(err) + } +} + +func TestForgeURLMustBeHTTPS(t *testing.T) { + env := map[string]string{"INFLUX_URL": "u", "INFLUX_TOKEN": "t", "INFLUX_ORG": "o", + "INGEST_TOKEN": "0123456789abcdef01234567", "FORGE_URL": "http://git.example.com"} + if _, err := loadConfig(func(k string) string { return env[k] }); err == nil { + t.Fatal("plain http forge accepted") + } +} diff --git a/server/store.go b/server/store.go new file mode 100644 index 0000000..0dab1a8 --- /dev/null +++ b/server/store.go @@ -0,0 +1,200 @@ +package main + +import ( + "context" + "fmt" + "strings" + "time" + + influxdb2 "github.com/influxdata/influxdb-client-go/v2" + "github.com/influxdata/influxdb-client-go/v2/api" + "github.com/influxdata/influxdb-client-go/v2/api/write" +) + +// Store is what the handlers need from the database; a fake implements it in tests. +type Store interface { + WriteTelemetry(ctx context.Context, installationID string, samples []TelemetrySample) error + WriteConsumptionChunk(ctx context.Context, chunk ConsumptionChunk) error + WriteChargingSession(ctx context.Context, session ChargingSession) error + ReadConsumptionChunks(ctx context.Context, installationID string) ([]ConsumptionChunk, error) + SaveDevice(ctx context.Context, d Device) error + LoadDevices(ctx context.Context) ([]Device, error) +} + +// InfluxStore keeps everything in one InfluxDB 2 bucket; every point carries an +// installationId tag. Grafana reads the same bucket directly. +// +// telemetry time = sample utc ABRP tlm fields (floats and booleans) +// 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 +// device time = createdAt tags: deviceId tokenHash, installationId, revoked +// +// 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 number is written as a float. InfluxDB fixes a field's type on first write, so +// a 0 written as an integer would make every later 0.5 a rejected write. +type InfluxStore struct { + client influxdb2.Client + write api.WriteAPIBlocking + query api.QueryAPI + bucket string +} + +const tagInstallation = "installationId" + +func NewInfluxStore(url, token, org, bucket string) *InfluxStore { + client := influxdb2.NewClientWithOptions(url, token, + influxdb2.DefaultOptions().SetHTTPRequestTimeout(10).SetPrecision(time.Millisecond)) + return &InfluxStore{ + client: client, + write: client.WriteAPIBlocking(org, bucket), + query: client.QueryAPI(org), + bucket: bucket, + } +} + +func (s *InfluxStore) Close() { s.client.Close() } + +func (s *InfluxStore) WriteTelemetry(ctx context.Context, id string, samples []TelemetrySample) error { + points := make([]*write.Point, 0, len(samples)) + for _, sample := range samples { + fields := make(map[string]any, len(sample.Numbers)+len(sample.Flags)) + for k, v := range sample.Numbers { + fields[k] = v + } + for k, v := range sample.Flags { + fields[k] = v + } + if len(fields) == 0 { + // A point needs at least one field; a bare timestamp says nothing anyway. + continue + } + points = append(points, influxdb2.NewPoint("telemetry", + map[string]string{tagInstallation: id}, fields, time.Unix(sample.UTC, 0))) + } + if len(points) == 0 { + return nil + } + return s.write.WritePoint(ctx, points...) +} + +func (s *InfluxStore) WriteConsumptionChunk(ctx context.Context, c ConsumptionChunk) error { + fields := map[string]any{"km": c.Day.Km, "kwh": c.Day.Kwh, "hours": c.Day.Hours, "socDrop": c.Day.SocDrop} + if c.SohPercent != nil { + fields["sohPercent"] = *c.SohPercent + } + return s.write.WritePoint(ctx, influxdb2.NewPoint("consumption_chunk", + map[string]string{tagInstallation: c.InstallationID, "date": c.Date}, fields, c.RecordedAt)) +} + +func (s *InfluxStore) WriteChargingSession(ctx context.Context, cs ChargingSession) error { + tags := map[string]string{tagInstallation: cs.InstallationID, "sessionId": cs.SessionID} + summary := map[string]any{ + "endedAtMs": cs.EndedAt.UnixMilli(), + "durationSeconds": cs.DurationSeconds, + "energyKwh": cs.EnergyKwh, + "chargingLossPercent": cs.ChargingLossPercent, + "gridEnergyKwh": cs.GridEnergyKwh, + "pricePerKwh": cs.PricePerKwh, + "totalCost": cs.TotalCost, + "isDemo": cs.IsDemo, + } + if cs.StartSocPercent != nil { + summary["startSocPercent"] = *cs.StartSocPercent + } + if cs.EndSocPercent != nil { + summary["endSocPercent"] = *cs.EndSocPercent + } + points := []*write.Point{influxdb2.NewPoint("charging_session", tags, summary, cs.StartedAt)} + for _, p := range cs.Curve { + fields := map[string]any{"powerKw": p.PowerKw} + if p.SocPercent != nil { + fields["socPercent"] = *p.SocPercent + } + points = append(points, influxdb2.NewPoint("charging_curve", tags, fields, p.Timestamp)) + } + return s.write.WritePoint(ctx, points...) +} + +// fluxString quotes a value as a Flux string literal. Ids are validated UUIDs before they +// get here; the escaping is for the bucket name and as a second line of defence. +func fluxString(value string) string { + return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`, "$", `\$`).Replace(value) + `"` +} + +func (s *InfluxStore) ReadConsumptionChunks(ctx context.Context, id string) ([]ConsumptionChunk, error) { + query := fmt.Sprintf(`from(bucket: %s) + |> range(start: 0) + |> filter(fn: (r) => r._measurement == "consumption_chunk" and r.installationId == %s) + |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")`, + fluxString(s.bucket), fluxString(id)) + result, err := s.query.Query(ctx, query) + if err != nil { + return nil, err + } + defer result.Close() + var chunks []ConsumptionChunk + for result.Next() { + r := result.Record() + date, _ := r.ValueByKey("date").(string) + chunk := ConsumptionChunk{ + InstallationID: id, + Date: date, + RecordedAt: r.Time(), + Day: Totals{ + Km: floatValue(r.ValueByKey("km")), + Kwh: floatValue(r.ValueByKey("kwh")), + Hours: floatValue(r.ValueByKey("hours")), + SocDrop: floatValue(r.ValueByKey("socDrop")), + }, + } + if soh, ok := r.ValueByKey("sohPercent").(float64); ok { + chunk.SohPercent = &soh + } + chunks = append(chunks, chunk) + } + return chunks, result.Err() +} + +func floatValue(v any) float64 { + f, _ := v.(float64) + return f +} + +// SaveDevice writes a paired car. Re-saving it (a revocation) overwrites the same point, +// because measurement, tag and timestamp stay the same. +func (s *InfluxStore) SaveDevice(ctx context.Context, d Device) error { + return s.write.WritePoint(ctx, influxdb2.NewPoint("device", + map[string]string{"deviceId": d.ID}, + map[string]any{"tokenHash": d.TokenHash, "installationId": d.InstallationID, "revoked": d.Revoked}, + d.CreatedAt)) +} + +func (s *InfluxStore) LoadDevices(ctx context.Context) ([]Device, error) { + query := fmt.Sprintf(`from(bucket: %s) + |> range(start: 0) + |> filter(fn: (r) => r._measurement == "device") + |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")`, fluxString(s.bucket)) + result, err := s.query.Query(ctx, query) + if err != nil { + return nil, err + } + defer result.Close() + var devices []Device + for result.Next() { + r := result.Record() + id, _ := r.ValueByKey("deviceId").(string) + hash, _ := r.ValueByKey("tokenHash").(string) + installation, _ := r.ValueByKey("installationId").(string) + revoked, _ := r.ValueByKey("revoked").(bool) + if id == "" || hash == "" { + continue + } + devices = append(devices, Device{ID: id, InstallationID: installation, TokenHash: hash, + CreatedAt: r.Time(), Revoked: revoked}) + } + return devices, result.Err() +} diff --git a/server/validate.go b/server/validate.go new file mode 100644 index 0000000..c82cbe3 --- /dev/null +++ b/server/validate.go @@ -0,0 +1,347 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "regexp" + "sort" + "time" +) + +// Request validation for the ingest endpoints, ported rule for rule from the TypeScript +// server. The car is the only writer and it is trusted, but a bad field is written once +// and read forever: every number is bounded, and an out-of-range value is rejected rather +// than stored and quietly plotted. + +// ValidationError is a 400: the message may name the bad field. +type ValidationError struct{ msg string } + +func (e *ValidationError) Error() string { return e.msg } + +func invalid(format string, args ...any) error { + return &ValidationError{msg: fmt.Sprintf(format, args...)} +} + +var ( + uuidPattern = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + isoDatePattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) +) + +const ( + maxSamples = 500 + maxCurvePoints = 720 + maxSafeInteger = 1<<53 - 1 + utcMin = 1_000_000_000 // epoch seconds: 2001… + utcMax = 9_999_999_999 // …2286; milliseconds would be far above + totalsMaxAbs = 10_000_000 + chargingLossPct = 10 +) + +// object is a decoded JSON object whose numbers stay json.Number, so integers and +// floats can be told apart and nothing is rounded on the way in. +type object map[string]any + +func decodeObject(body []byte) (object, error) { + dec := json.NewDecoder(bytes.NewReader(body)) + dec.UseNumber() + var value any + if err := dec.Decode(&value); err != nil { + return nil, invalid("body is not valid JSON") + } + if dec.More() { + return nil, invalid("body is not valid JSON") + } + return asObject(value, "body") +} + +func asObject(value any, name string) (object, error) { + o, ok := value.(map[string]any) + if !ok { + if name == "body" { + return nil, invalid("body is required") + } + return nil, invalid("%s is invalid", name) + } + return object(o), nil +} + +func (o object) has(key string) bool { + v, ok := o[key] + return ok && v != nil +} + +func number(value any, name string) (float64, error) { + n, ok := value.(json.Number) + if !ok { + return 0, invalid("%s is invalid", name) + } + f, err := n.Float64() + if err != nil || math.IsNaN(f) || math.IsInf(f, 0) { + return 0, invalid("%s is invalid", name) + } + return f, nil +} + +func bounded(value any, min, max float64, name string) (float64, error) { + f, err := number(value, name) + if err != nil || f < min || f > max { + return 0, invalid("%s is invalid", name) + } + return f, nil +} + +// integer accepts a whole, non-negative number up to 2^53-1 — what JavaScript and the +// car's JSON can carry exactly. +func integer(value any, name string) (int64, error) { + f, err := number(value, name) + if err != nil || f != math.Trunc(f) || f < 0 || f > maxSafeInteger { + return 0, invalid("%s is invalid", name) + } + return int64(f), nil +} + +func validDate(value any, name string) (time.Time, error) { + ms, err := integer(value, name) + if err != nil { + return time.Time{}, err + } + return time.UnixMilli(ms).UTC(), nil +} + +func validUUID(value any, name string) (string, error) { + s, ok := value.(string) + if !ok || !uuidPattern.MatchString(s) { + return "", invalid("%s is invalid", name) + } + return s, nil +} + +func optionalBounded(o object, key string, min, max float64, name string) (*float64, error) { + if !o.has(key) { + return nil, nil + } + f, err := bounded(o[key], min, max, name) + if err != nil { + return nil, err + } + return &f, nil +} + +func validTotals(value any, name string) (Totals, error) { + o, err := asObject(value, name) + if err != nil { + return Totals{}, err + } + var t Totals + for _, field := range []struct { + key string + dst *float64 + }{{"km", &t.Km}, {"kwh", &t.Kwh}, {"hours", &t.Hours}, {"socDrop", &t.SocDrop}} { + f, err := number(o[field.key], name+"."+field.key) + if err != nil || math.Abs(f) > totalsMaxAbs { + return Totals{}, invalid("%s.%s is invalid", name, field.key) + } + *field.dst = f + } + return t, nil +} + +func validateConsumption(body []byte) (ConsumptionChunk, error) { + o, err := decodeObject(body) + if err != nil { + return ConsumptionChunk{}, err + } + date, ok := o["date"].(string) + if !ok || !isoDatePattern.MatchString(date) { + return ConsumptionChunk{}, invalid("date is invalid") + } + id, err := validUUID(o["installationId"], "installationId") + if err != nil { + return ConsumptionChunk{}, err + } + recordedAt, err := validDate(o["recordedAt"], "recordedAt") + if err != nil { + return ConsumptionChunk{}, err + } + day, err := validTotals(o["day"], "day") + if err != nil { + return ConsumptionChunk{}, err + } + // A lifetime from an older app is still validated, so a malformed one is still a 400, + // but it is not stored: lifetime is derived from the day totals when read. + if o.has("lifetime") { + if _, err := validTotals(o["lifetime"], "lifetime"); err != nil { + return ConsumptionChunk{}, err + } + } + soh, err := optionalBounded(o, "sohPercent", 0, 100, "sohPercent") + if err != nil { + return ConsumptionChunk{}, err + } + return ConsumptionChunk{InstallationID: id, Date: date, RecordedAt: recordedAt, Day: day, SohPercent: soh}, nil +} + +func validateChargingSession(body []byte, pathSessionID string) (ChargingSession, error) { + o, err := decodeObject(body) + if err != nil { + return ChargingSession{}, err + } + sessionID, err := validUUID(o["sessionId"], "sessionId") + if err != nil { + return ChargingSession{}, err + } + if sessionID != pathSessionID { + return ChargingSession{}, invalid("sessionId does not match path") + } + rawCurve, ok := o["curve"].([]any) + if !ok || len(rawCurve) > maxCurvePoints { + return ChargingSession{}, invalid("curve is invalid") + } + curve := make([]CurvePoint, 0, len(rawCurve)) + for _, entry := range rawCurve { + p, err := asObject(entry, "curve") + if err != nil { + return ChargingSession{}, err + } + ts, err := validDate(p["timestampMs"], "curve.timestampMs") + if err != nil { + return ChargingSession{}, err + } + power, err := bounded(p["powerKw"], 0, 1000, "curve.powerKw") + if err != nil { + return ChargingSession{}, err + } + soc, err := optionalBounded(p, "socPercent", 0, 100, "curve.socPercent") + if err != nil { + return ChargingSession{}, err + } + curve = append(curve, CurvePoint{Timestamp: ts, PowerKw: power, SocPercent: soc}) + } + s := ChargingSession{SessionID: sessionID, Curve: curve, ChargingLossPercent: chargingLossPct} + if s.EnergyKwh, err = bounded(o["energyKwh"], 0, 1000, "energyKwh"); err != nil { + return ChargingSession{}, err + } + if s.PricePerKwh, err = bounded(o["pricePerKwh"], 0, 1_000_000, "pricePerKwh"); err != nil { + return ChargingSession{}, err + } + if s.InstallationID, err = validUUID(o["installationId"], "installationId"); err != nil { + return ChargingSession{}, err + } + if s.StartedAt, err = validDate(o["startedAt"], "startedAt"); err != nil { + return ChargingSession{}, err + } + if s.EndedAt, err = validDate(o["endedAt"], "endedAt"); err != nil { + return ChargingSession{}, err + } + if s.DurationSeconds, err = bounded(o["durationSeconds"], 0, 604800, "durationSeconds"); err != nil { + return ChargingSession{}, err + } + if s.StartSocPercent, err = optionalBounded(o, "startSocPercent", 0, 100, "startSocPercent"); err != nil { + return ChargingSession{}, err + } + if s.EndSocPercent, err = optionalBounded(o, "endSocPercent", 0, 100, "endSocPercent"); err != nil { + return ChargingSession{}, err + } + // The car's own loss and grid figures are not trusted: the server applies its fixed 10 %. + s.GridEnergyKwh = s.EnergyKwh * 1.1 + s.TotalCost = s.GridEnergyKwh * s.PricePerKwh + s.IsDemo = o["isDemo"] == true + return s, nil +} + +// validateTelemetryBatch checks a batch of live samples in ABRP's tlm field names and +// units. Unknown fields are dropped rather than rejected, so a newer app can add one +// without a server release; a flag may be 0/1 or a boolean. +// +// Only a malformed request is a 400. A bad value inside a sample costs that value, and a +// sample without a usable time costs that sample: the car drops a batch the server +// rejects, and one sensor reading out of range must not take hours of queued driving with +// it. What was dropped is listed in Dropped, for the response and the log. +func validateTelemetryBatch(body []byte, now time.Time) (TelemetryBatch, error) { + o, err := decodeObject(body) + if err != nil { + return TelemetryBatch{}, err + } + id, err := validUUID(o["installationId"], "installationId") + if err != nil { + return TelemetryBatch{}, err + } + raw, ok := o["samples"].([]any) + if !ok || len(raw) == 0 || len(raw) > maxSamples { + return TelemetryBatch{}, invalid("samples is invalid") + } + batch := TelemetryBatch{InstallationID: id, Samples: make([]TelemetrySample, 0, len(raw))} + latest := now.Add(maxClockAhead).Unix() + for i, entry := range raw { + name := fmt.Sprintf("samples[%d]", i) + sample, dropped, ok := validateSample(entry, name, latest) + batch.Dropped = append(batch.Dropped, dropped...) + if ok { + batch.Samples = append(batch.Samples, sample) + } + } + return batch, nil +} + +// maxClockAhead is how far in the future a sample may be stamped. The head unit sets its +// clock from GPS; a sample from further ahead has a wrong time, and stored there it would +// pose as the car's latest state until that time came. +const maxClockAhead = time.Hour + +// validateSample keeps every valid field of one sample. It returns false when the sample +// as a whole is unusable: not an object, or no plausible time. dropped names what was left +// out. +func validateSample(entry any, name string, latestUTC int64) (sample TelemetrySample, dropped []string, ok bool) { + o, err := asObject(entry, name) + if err != nil { + return TelemetrySample{}, []string{name}, false + } + utc, err := integer(o["utc"], name+".utc") + if err != nil || utc < utcMin || utc > utcMax || utc > latestUTC { + return TelemetrySample{}, []string{name + ".utc"}, false + } + sample = TelemetrySample{UTC: utc, Numbers: map[string]float64{}, Flags: map[string]bool{}} + for field, limits := range telemetryNumbers { + if !o.has(field) { + continue + } + f, err := bounded(o[field], limits[0], limits[1], name+"."+field) + if err != nil { + dropped = append(dropped, name+"."+field) + continue + } + sample.Numbers[field] = f + } + for _, field := range telemetryFlags { + if !o.has(field) { + continue + } + switch v := o[field].(type) { + case bool: + sample.Flags[field] = v + case json.Number: + switch v.String() { + case "1": + sample.Flags[field] = true + case "0": + sample.Flags[field] = false + default: + dropped = append(dropped, name+"."+field) + } + default: + dropped = append(dropped, name+"."+field) + } + } + // A position is only a position as a pair. + _, hasLat := sample.Numbers["lat"] + _, hasLon := sample.Numbers["lon"] + if hasLat != hasLon { + delete(sample.Numbers, "lat") + delete(sample.Numbers, "lon") + dropped = append(dropped, name+".lat/lon") + } + sort.Strings(dropped) + return sample, dropped, true +} diff --git a/server/validate_test.go b/server/validate_test.go new file mode 100644 index 0000000..8d05212 --- /dev/null +++ b/server/validate_test.go @@ -0,0 +1,207 @@ +package main + +import ( + "errors" + "strings" + "testing" + "time" +) + +const ( + testInstallation = "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b" + testSession = "11111111-2222-4333-8444-555555555555" + testNowS = 1_790_000_000 +) + +// testNow is the sample time the telemetry tests send, as "now" for the clock-ahead check. +var testNow = time.Unix(testNowS, 0) + +func isValidationError(err error) bool { + var v *ValidationError + return errors.As(err, &v) +} + +func TestConsumptionAcceptsAWellFormedChunk(t *testing.T) { + c, err := validateConsumption([]byte(`{"installationId":"` + testInstallation + `","date":"2026-10-07", + "recordedAt":1760000000000,"day":{"km":42.5,"kwh":7.25,"hours":0.9,"socDrop":11.5}, + "lifetime":{"km":12000,"kwh":2100,"hours":240,"socDrop":3400},"sohPercent":97.5}`)) + if err != nil { + t.Fatal(err) + } + if c.Date != "2026-10-07" || c.Day.Kwh != 7.25 || c.RecordedAt.UnixMilli() != 1760000000000 || *c.SohPercent != 97.5 { + t.Fatalf("unexpected chunk %+v", c) + } +} + +func TestConsumptionMissingSohIsOmittedNotDefaulted(t *testing.T) { + // The car omits a field it could not read. Writing 0 would be a battery at nothing. + c, err := validateConsumption([]byte(`{"installationId":"` + testInstallation + `","date":"2026-10-07", + "recordedAt":1,"day":{"km":1,"kwh":1,"hours":1,"socDrop":1}}`)) + if err != nil { + t.Fatal(err) + } + if c.SohPercent != nil { + t.Fatalf("SoH should be absent, got %v", *c.SohPercent) + } +} + +func TestConsumptionRejectsBadInput(t *testing.T) { + base := map[string]string{ + "installationId": `"` + testInstallation + `"`, "date": `"2026-10-07"`, "recordedAt": "1", + "day": `{"km":1,"kwh":1,"hours":1,"socDrop":1}`, + } + cases := map[string][2]string{ + "bad id": {"installationId", `"nope"`}, + "german date": {"date", `"07.10.2026"`}, + "negative timestamp": {"recordedAt", "-5"}, + "fractional time": {"recordedAt", "1.5"}, + "string number": {"day", `{"km":"1","kwh":1,"hours":1,"socDrop":1}`}, + "huge totals": {"day", `{"km":1e9,"kwh":1,"hours":1,"socDrop":1}`}, + "missing field": {"day", `{"km":1,"kwh":1,"hours":1}`}, + "bad lifetime": {"lifetime", `{"km":1}`}, + "soh above 100": {"sohPercent", "101"}, + } + for name, change := range cases { + fields := map[string]string{} + for k, v := range base { + fields[k] = v + } + fields[change[0]] = change[1] + var parts []string + for k, v := range fields { + parts = append(parts, `"`+k+`":`+v) + } + if _, err := validateConsumption([]byte("{" + strings.Join(parts, ",") + "}")); !isValidationError(err) { + t.Errorf("%s: want ValidationError, got %v", name, err) + } + } + for _, body := range []string{"", "[]", "null", "{", `{"a":1}{"b":2}`} { + if _, err := validateConsumption([]byte(body)); !isValidationError(err) { + t.Errorf("body %q: want ValidationError, got %v", body, err) + } + } +} + +func chargingBody(overrides string) []byte { + return []byte(`{"installationId":"` + testInstallation + `","sessionId":"` + testSession + `", + "startedAt":1760500000000,"endedAt":1760501800000,"durationSeconds":1800,"energyKwh":30, + "pricePerKwh":0.59,"startSocPercent":20,"endSocPercent":80, + "curve":[{"timestampMs":1760500060000,"powerKw":95,"socPercent":21},{"timestampMs":1760500120000,"powerKw":110}]` + + overrides + `}`) +} + +func TestChargingSessionAppliesTheServersLossAndCost(t *testing.T) { + // The car's own grid and cost figures are not trusted; the server applies 10 % loss. + s, err := validateChargingSession(chargingBody(`,"gridEnergyKwh":1,"totalCost":1`), testSession) + if err != nil { + t.Fatal(err) + } + if s.GridEnergyKwh != 33 || s.TotalCost != 33*0.59 || s.ChargingLossPercent != 10 { + t.Fatalf("grid %v cost %v loss %v", s.GridEnergyKwh, s.TotalCost, s.ChargingLossPercent) + } + if len(s.Curve) != 2 || s.Curve[1].SocPercent != nil || *s.Curve[0].SocPercent != 21 { + t.Fatalf("curve not kept as sent: %+v", s.Curve) + } +} + +func TestChargingSessionMustMatchItsPath(t *testing.T) { + if _, err := validateChargingSession(chargingBody(""), "22222222-2222-4333-8444-555555555555"); !isValidationError(err) { + t.Fatalf("want ValidationError, got %v", err) + } +} + +func TestChargingSessionRejectsOversizedCurvesAndBadPoints(t *testing.T) { + points := strings.Repeat(`{"timestampMs":1,"powerKw":1},`, 721) + long := []byte(`{"installationId":"` + testInstallation + `","sessionId":"` + testSession + `","curve":[` + + strings.TrimSuffix(points, ",") + `]}`) + if _, err := validateChargingSession(long, testSession); !isValidationError(err) { + t.Errorf("721 points: want ValidationError, got %v", err) + } + bad := []byte(strings.Replace(string(chargingBody("")), `"powerKw":95`, `"powerKw":-5`, 1)) + if _, err := validateChargingSession(bad, testSession); !isValidationError(err) { + t.Errorf("negative power: want ValidationError, got %v", err) + } +} + +func telemetryBody(samples string) []byte { + return []byte(`{"installationId":"` + testInstallation + `","samples":[` + samples + `]}`) +} + +func TestTelemetryKeepsOnlyWhatWasSent(t *testing.T) { + b, err := validateTelemetryBatch(telemetryBody( + `{"utc":1790000000,"soc":81,"power":-12.5,"speed":0,"lat":52.1,"lon":7.6,"is_charging":1,"is_dcfc":false}`), testNow) + if err != nil { + t.Fatal(err) + } + s := b.Samples[0] + if s.UTC != testNowS || s.Numbers["soc"] != 81 || s.Numbers["power"] != -12.5 || s.Numbers["lat"] != 52.1 { + t.Fatalf("numbers %+v", s.Numbers) + } + if v, ok := s.Numbers["speed"]; !ok || v != 0 { + t.Fatal("a real 0 must be kept") + } + if s.Flags["is_charging"] != true || s.Flags["is_dcfc"] != false || len(s.Flags) != 2 { + t.Fatalf("flags %+v", s.Flags) + } + // An unread signal stays absent: a missing SOC must never become 0. + if _, ok := s.Numbers["est_battery_range"]; ok { + t.Fatal("absent field invented") + } +} + +func TestTelemetryDropsUnknownFields(t *testing.T) { + b, err := validateTelemetryBatch(telemetryBody(`{"utc":1790000000,"future_field":3}`), testNow) + if err != nil { + t.Fatal(err) + } + if len(b.Samples[0].Numbers) != 0 || len(b.Samples[0].Flags) != 0 { + t.Fatalf("unknown field kept: %+v", b.Samples[0]) + } +} + +func TestTelemetryDropsBadValuesAndKeepsTheRest(t *testing.T) { + for name, c := range map[string]struct { + sample string + dropped string + kept bool // the sample itself survives + }{ + "milliseconds": {`{"utc":1790000000000,"soc":5}`, "samples[0].utc", false}, + "fractional utc": {`{"utc":1790000000.5,"soc":5}`, "samples[0].utc", false}, + "clock unset": {`{"utc":0,"soc":5}`, "samples[0].utc", false}, + "clock a day ahead": {`{"utc":1790086400,"soc":5}`, "samples[0].utc", false}, + "not an object": {`5`, "samples[0]", false}, + "soc above 100": {`{"utc":1790000000,"soc":101,"speed":20}`, "samples[0].soc", true}, + "reversing": {`{"utc":1790000000,"soc":5,"speed":-4}`, "samples[0].speed", true}, + "string speed": {`{"utc":1790000000,"soc":5,"speed":"fast"}`, "samples[0].speed", true}, + "flag 2": {`{"utc":1790000000,"soc":5,"is_parked":2}`, "samples[0].is_parked", true}, + "half position": {`{"utc":1790000000,"soc":5,"lat":52.1}`, "samples[0].lat/lon", true}, + } { + b, err := validateTelemetryBatch(telemetryBody(c.sample), testNow) + if err != nil { + t.Errorf("%s: the batch was rejected: %v", name, err) + continue + } + if len(b.Dropped) != 1 || b.Dropped[0] != c.dropped { + t.Errorf("%s: dropped %v, want %s", name, b.Dropped, c.dropped) + } + if (len(b.Samples) == 1) != c.kept { + t.Errorf("%s: %d samples kept", name, len(b.Samples)) + } + if c.kept && b.Samples[0].Numbers["soc"] != 5 && name != "soc above 100" { + t.Errorf("%s: the valid soc was lost", name) + } + } +} + +func TestTelemetryRejectsEmptyAndOversizedBatches(t *testing.T) { + if _, err := validateTelemetryBatch(telemetryBody(""), testNow); !isValidationError(err) { + t.Error("empty batch accepted") + } + many := strings.TrimSuffix(strings.Repeat(`{"utc":1790000000},`, 501), ",") + if _, err := validateTelemetryBatch(telemetryBody(many), testNow); !isValidationError(err) { + t.Error("501 samples accepted") + } + if _, err := validateTelemetryBatch([]byte(`{"installationId":"nope","samples":[{"utc":1790000000}]}`), testNow); !isValidationError(err) { + t.Error("bad installation id accepted") + } +} diff --git a/settings.gradle b/settings.gradle index 98c922f..c8d83b5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -22,7 +22,7 @@ dependencyResolutionManagement { } } -rootProject.name = "FatihsMG4" +rootProject.name = "NilsMG4" include ':app' include ':evhardware' project(':evhardware').projectDir = file('EVHardware/lib') diff --git a/web/.env.example b/web/.env.example deleted file mode 100644 index 5cd72df..0000000 --- a/web/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -MONGODB_URI=mongodb+srv://username:password@cluster.example.mongodb.net/ -MONGODB_DATABASE=fatihsmg4 -INGEST_TOKEN=replace-with-a-long-random-write-token -SESSION_SECRET=replace-with-a-long-random-session-secret -DASHBOARD_PASSWORD=replace-with-a-strong-dashboard-password diff --git a/web/.gitignore b/web/.gitignore deleted file mode 100644 index 27dfd45..0000000 --- a/web/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -node_modules -.next -# Every .env variant is secret except the committed template. -.env* -!.env.example -next-env.d.ts -*.tsbuildinfo diff --git a/web/README.md b/web/README.md deleted file mode 100644 index 9715a88..0000000 --- a/web/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# FatihsMG4 web - -Next.js app on Vercel: the ingest API the car writes to, and a phone-shaped dashboard for -reading the history back. Replaces the standalone Express service that used to live in -`server/`. - -**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. - -## Two credentials, on purpose - -| | Who holds it | What it opens | -|---|---|---| -| `INGEST_TOKEN` | the car, in the APK | the `PUT` endpoints — write only | -| `DASHBOARD_PASSWORD` | you, in a browser | the dashboard and the `GET` history | - -The APK is published, so its token must be assumed extractable: anyone can `unzip` an APK -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 - -Set all five in Vercel → Settings → Environment Variables, for Production and Preview. - -| Name | Notes | -|---|---| -| `MONGODB_URI` | Atlas connection string | -| `MONGODB_DATABASE` | defaults to `fatihsmg4` | -| `INGEST_TOKEN` | `openssl rand -hex 32` | -| `DASHBOARD_PASSWORD` | at least 8 characters, or login is refused outright | -| `SESSION_SECRET` | `openssl rand -hex 32`; rotating it signs every browser out | - -Atlas network access must allow Vercel's egress, which is not a fixed address on the Hobby -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 - -Write, `Authorization: Bearer `: - -- `PUT /v1/consumption/daily` -- `PUT /v1/charging-sessions/:sessionId` - -Read, session cookie or `Authorization: Bearer `: - -- `GET /v1/history/:installationId/consumption` -- `GET /v1/history/:installationId/charging` - -`GET /v1/health` is unauthenticated and returns `{"status":"ok"}`. - -The car builds its URLs as `CONSUMPTION_API_URL + /v1/...`, so the Android build variable -is the bare origin — `https://your-app.vercel.app`, no trailing path. - -## Indexes - -There is no boot step on Vercel, so `ensureIndexes()` runs on the first write of each warm -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 -record it could not confirm. - -## Local development - -```sh -cp .env.example .env.local # Next loads .env.local automatically -npm install -npm run dev -npm test # pure validation and session-signing tests, no database -``` diff --git a/web/next.config.mjs b/web/next.config.mjs deleted file mode 100644 index 1f0ce49..0000000 --- a/web/next.config.mjs +++ /dev/null @@ -1,6 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - // Every page reads live telemetry; a cached shell would show yesterday's drive. - experimental: { serverActions: { bodySizeLimit: "1mb" } }, -}; -export default nextConfig; diff --git a/web/package-lock.json b/web/package-lock.json deleted file mode 100644 index d4aa3dd..0000000 --- a/web/package-lock.json +++ /dev/null @@ -1,1145 +0,0 @@ -{ - "name": "fatihsmg4-web", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "fatihsmg4-web", - "version": "1.0.0", - "dependencies": { - "mongodb": "^6.20.0", - "next": "^15.5.4", - "react": "^19.1.0", - "react-dom": "^19.1.0" - }, - "devDependencies": { - "@types/node": "^22.15.3", - "@types/react": "^19.1.2", - "typescript": "^5.8.3" - }, - "engines": { - "node": ">=22" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", - "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", - "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", - "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.4" - } - }, - "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", - "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "dependencies": { - "@img/sharp-wasm32": "0.35.5" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", - "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", - "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", - "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", - "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", - "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", - "cpu": [ - "ppc64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", - "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", - "cpu": [ - "riscv64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", - "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", - "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", - "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", - "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", - "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.4" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", - "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.4" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", - "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", - "cpu": [ - "ppc64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.4" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", - "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", - "cpu": [ - "riscv64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", - "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", - "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", - "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", - "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", - "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.11.3" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", - "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@img/sharp-wasm32": "0.35.5" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", - "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", - "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", - "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@mongodb-js/saslprep": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.6.0.tgz", - "integrity": "sha512-19jGXfpctLDJUELLUZGJ5BllcLT7rHdF+gTTaN2jzqu0bGkUhpdcYtgK2Vux+g3zuBAa0TM/W666QMIjYhYujw==", - "license": "MIT", - "dependencies": { - "sparse-bitfield": "^3.0.3" - } - }, - "node_modules/@next/env": { - "version": "15.5.27", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.27.tgz", - "integrity": "sha512-WC5hvVqiuFOcRyz6gKPeJkiVvSMf3scNeAV49CdWtVqxmUlzh8UKR6YHt9yiNkh8LngtzqSlVz07/Ma3ZAzchA==", - "license": "MIT" - }, - "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.27", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.27.tgz", - "integrity": "sha512-fBA9QurmK4+uj4wNhmRTuO5zMVRaBHCvp6H93zTG/5i6Yi6TewbvbesXB8YQNj8T99Cwrow87hHiFV6wL/hyMA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "15.5.27", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.27.tgz", - "integrity": "sha512-FGZwJwry8qZNHxD/nfwrCwCHHrqx6epqItkkxbYcBQEQ2uw5S43bY83fQB5zbAsZE7TELMjoYkbhsQ4oKM7wwg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.27", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.27.tgz", - "integrity": "sha512-GQulAaJYNF1RYuatlsw9/uzbKz4if2V7Ic9nVO+P0ljYPm4yBRTFIl6g5YkKTw40lMLbpy9edLoH44g3jSHMYA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.27", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.27.tgz", - "integrity": "sha512-URFFsUmu8Pi0/1qdnUI7tx7keZw1Mt5zHfrIX1RfDG9Nih8DYxK54WZsDJUX2ZTlii1jgotL7qcbwKnSAES6xQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.27", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.27.tgz", - "integrity": "sha512-aJp74z7uvU5jQhyH7lyk+4qSRcTdKbKT9NQE3jiO9kWYkoHZjKi0zzHDbCoZ+v2sJDEiJ/z58R0UlWMGWNF3HA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.27", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.27.tgz", - "integrity": "sha512-ILl2bQ8MbpvyMpY8AXljb3BXs0MJSC8HXZ6QV5AWpVS7ZELqNPtrHvhUk+SNarvjvICXX8bCS287TCi+gUFVYg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.27", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.27.tgz", - "integrity": "sha512-1MB9Rh5y8TOPN2x7naNTJNkCZQav9c70AI7EBYXxsEewwOffC4DjjwBwKf20NBvcA+u+5jDv7nVmHxunlP2d7Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.27", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.27.tgz", - "integrity": "sha512-XtYgpGOswgnyBWXzOEG6q9RhvnSw0CEZ0MbWqCsl5PISgt9EzvJvNW5mU5n1PsphX/lnamzxjgQXH2GUJL8nqg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@types/node": { - "version": "22.20.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", - "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/react": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", - "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "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": { - "version": "1.0.30001815", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", - "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/client-only": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "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": { - "version": "3.3.20", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", - "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/next": { - "version": "15.5.27", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.27.tgz", - "integrity": "sha512-F82CrlPZ8GRxBH9RIIoR3qU1t5RT3dv1K8moe8SjlDEquiFipvEI13R1Om90TaUOEwShkWCMlg8+gvTNzfMhvg==", - "license": "MIT", - "dependencies": { - "@next/env": "15.5.27", - "@swc/helpers": "0.5.15", - "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", - "styled-jsx": "5.1.6" - }, - "bin": { - "next": "dist/bin/next" - }, - "engines": { - "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" - }, - "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.27", - "@next/swc-darwin-x64": "15.5.27", - "@next/swc-linux-arm64-gnu": "15.5.27", - "@next/swc-linux-arm64-musl": "15.5.27", - "@next/swc-linux-x64-gnu": "15.5.27", - "@next/swc-linux-x64-musl": "15.5.27", - "@next/swc-win32-arm64-msvc": "15.5.27", - "@next/swc-win32-x64-msvc": "15.5.27", - "sharp": "^0.34.3 || ^0.35.4" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.51.1", - "babel-plugin-react-compiler": "*", - "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "sass": "^1.3.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@playwright/test": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - }, - "sass": { - "optional": true - } - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "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": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", - "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", - "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", - "license": "MIT", - "peer": true, - "dependencies": { - "scheduler": "^0.28.0" - }, - "peerDependencies": { - "react": "^19.3.0" - } - }, - "node_modules/scheduler": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", - "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sharp": { - "version": "0.35.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", - "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@img/colour": "^1.1.0", - "detect-libc": "^2.1.2", - "semver": "^7.8.5" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.5", - "@img/sharp-darwin-x64": "0.35.5", - "@img/sharp-freebsd-wasm32": "0.35.5", - "@img/sharp-libvips-darwin-arm64": "1.3.4", - "@img/sharp-libvips-darwin-x64": "1.3.4", - "@img/sharp-libvips-linux-arm": "1.3.4", - "@img/sharp-libvips-linux-arm64": "1.3.4", - "@img/sharp-libvips-linux-ppc64": "1.3.4", - "@img/sharp-libvips-linux-riscv64": "1.3.4", - "@img/sharp-libvips-linux-s390x": "1.3.4", - "@img/sharp-libvips-linux-x64": "1.3.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", - "@img/sharp-libvips-linuxmusl-x64": "1.3.4", - "@img/sharp-linux-arm": "0.35.5", - "@img/sharp-linux-arm64": "0.35.5", - "@img/sharp-linux-ppc64": "0.35.5", - "@img/sharp-linux-riscv64": "0.35.5", - "@img/sharp-linux-s390x": "0.35.5", - "@img/sharp-linux-x64": "0.35.5", - "@img/sharp-linuxmusl-arm64": "0.35.5", - "@img/sharp-linuxmusl-x64": "0.35.5", - "@img/sharp-webcontainers-wasm32": "0.35.5", - "@img/sharp-win32-arm64": "0.35.5", - "@img/sharp-win32-ia32": "0.35.5", - "@img/sharp-win32-x64": "0.35.5" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/source-map-js": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", - "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", - "license": "BSD-3-Clause", - "engines": { - "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": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", - "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", - "license": "MIT", - "dependencies": { - "client-only": "0.0.1" - }, - "engines": { - "node": ">= 12.0.0" - }, - "peerDependencies": { - "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "babel-plugin-macros": { - "optional": true - } - } - }, - "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": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "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" - } - } - } -} diff --git a/web/package.json b/web/package.json deleted file mode 100644 index 0cf8888..0000000 --- a/web/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "fatihsmg4-web", - "version": "1.0.0", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint", - "test": "node --test --experimental-strip-types test/*.test.ts" - }, - "engines": { - "node": ">=22" - }, - "dependencies": { - "mongodb": "^6.20.0", - "next": "^15.5.4", - "react": "^19.1.0", - "react-dom": "^19.1.0" - }, - "devDependencies": { - "@types/node": "^22.15.3", - "@types/react": "^19.1.2", - "typescript": "^5.8.3" - }, - "type": "module" -} \ No newline at end of file diff --git a/web/src/app/api/session/route.ts b/web/src/app/api/session/route.ts deleted file mode 100644 index 9cbbcda..0000000 --- a/web/src/app/api/session/route.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { NextResponse } from "next/server"; -import { SESSION_COOKIE, checkDashboardPassword, issueSessionValue, sessionCookieOptions } from "@/lib/session"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * Deliberately slow to fail. The password is the only thing between the open internet and - * the history, and a quarter second is unnoticeable once but ruinous for anyone working - * through a list. - */ -export async function POST(request: Request) { - const form = await request.formData(); - const password = String(form.get("password") ?? ""); - if (!checkDashboardPassword(password)) { - await new Promise((resolve) => setTimeout(resolve, 250)); - return NextResponse.redirect(new URL("/login?error=1", request.url), { status: 303 }); - } - const response = NextResponse.redirect(new URL("/", request.url), { status: 303 }); - // Set on the response rather than through the cookies() store: a freshly constructed - // redirect does not reliably carry what that store recorded. - response.cookies.set(SESSION_COOKIE, issueSessionValue(), sessionCookieOptions); - return response; -} diff --git a/web/src/app/charging/page.tsx b/web/src/app/charging/page.tsx deleted file mode 100644 index 16f8279..0000000 --- a/web/src/app/charging/page.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { requireSession } from "@/lib/auth"; -import { loadOverview, sumCharging } from "@/lib/data"; -import { dateTime, duration, money, num, precise } from "@/lib/format"; -import { ChargeCurveChart } from "@/components/charts"; -import { Row, Shell, Tile } from "@/components/shell"; - -export const dynamic = "force-dynamic"; - -export default async function ChargingPage() { - await requireSession(); - const { installationId, sessions } = await loadOverview(1, 200); - - if (!installationId || sessions.length === 0) { - return ( - -
-

- Şarj kaydı yok. Yalnızca 10 kW üstü DC şarjlar ölçülür; AC şarj kaydedilmez. -

-
-
- ); - } - - const total = sumCharging(sessions); - - return ( - -
- - - - 0 ? money(total.cost / total.gridEnergyKwh) : "—"} - note="kWh başına" - tone="teal" - /> - -
- -
-

Seanslar

-
    - {sessions.map((session) => { - const gained = - session.startSocPercent !== undefined && session.endSocPercent !== undefined - ? session.endSocPercent - session.startSocPercent - : null; - const peak = session.curve.reduce((max, point) => Math.max(max, point.powerKw), 0); - const average = - session.durationSeconds > 0 ? (session.energyKwh * 3600) / session.durationSeconds : 0; - return ( -
  • -
    - -
    -
    {dateTime(session.startedAt)}
    -
    - {session.isDemo && "Demo · "} - {duration(session.durationSeconds)} - {gained !== null && ` · %${num(session.startSocPercent as number)} → %${num(session.endSocPercent as number)}`} -
    -
    -
    - {precise(session.energyKwh)} - kWh -
    {money(session.totalCost)}
    -
    -
    -
    - ({ - timestamp: new Date(point.timestamp).toISOString(), - powerKw: point.powerKw, - socPercent: point.socPercent, - }))} - /> -
    - - - - - - - 0 ? `${num(peak)} kW` : "—"} /> - 0 ? `${num(average)} kW` : "—"} /> - - -
    -
    -
    -
  • - ); - })} -
-
-
- ); -} diff --git a/web/src/app/consumption/page.tsx b/web/src/app/consumption/page.tsx deleted file mode 100644 index 163546a..0000000 --- a/web/src/app/consumption/page.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import Link from "next/link"; -import { requireSession } from "@/lib/auth"; -import { loadOverview, sumDays } from "@/lib/data"; -import { duration, efficiency, isoDate, num, precise } from "@/lib/format"; -import { DailyEnergyChart, EfficiencyChart } from "@/components/charts"; -import { Row, Shell, Tile } from "@/components/shell"; - -export const dynamic = "force-dynamic"; - -export default async function ConsumptionPage({ - searchParams, -}: { - searchParams: Promise<{ date?: string }>; -}) { - await requireSession(); - const { date: selectedDate } = await searchParams; - const { installationId, days } = await loadOverview(90, 1); - - if (!installationId || days.length === 0) { - return ( - -
-

Günlük tüketim kaydı yok. Araç günü kapattığında yüklenir.

-
-
- ); - } - - const chartDays = days.map((day) => ({ date: day.date, kwh: day.day.kwh, km: day.day.km })); - const total = sumDays(days); - // Lifetime is derived: the sum of every day's totals, same as the backend computes. - const lifetime = { km: total.km, kwh: total.kwh, hours: total.hours, socDrop: 0 }; - // Newest first for reading; the charts above keep chronological order. - const table = [...days].reverse(); - - // Find the selected day's record (if any). table is newest-first; a Map would be overkill - // for ≤90 entries. - const selected = selectedDate ? table.find((d) => d.date === selectedDate) : null; - const hasFilter = !!selectedDate; - const validFilter = hasFilter && !!selected; - - // Date input bounds: oldest record to today. - const minDate = days[0].date; // days is chronological (oldest first after loadOverview reverse) - const maxDate = new Date().toISOString().slice(0, 10); - - return ( - - {/* Date filter */} -
-
- -
- - - {hasFilter && ( - Temizle - )} -
-
-
- - {/* Selected day detail — shown above the period tiles when a filter is active */} - {hasFilter && ( -
-

{isoDate(selectedDate!)}

- {validFilter && selected ? ( - <> -
- - - - 0 ? num(selected.day.km / selected.day.hours) : "—"} - unit={selected.day.hours > 0 ? "km/h" : undefined} - /> -
-
- - - -
- - ) : ( -

Bu tarih için tüketim kaydı yok.

- )} -
- )} - -
- - - - 0 ? num(total.km / total.hours) : "—"} - unit={total.hours > 0 ? "km/h" : undefined} - /> -
- -
-

Günlük enerji

- -
- -
-

Verimlilik eğilimi

- -
- -
-

Ömür boyu

-
- - - - -
-
- -
-

Gün gün

-
    - {table.map((day) => { - const isSelected = hasFilter && day.date === selectedDate; - return ( -
  • -
    - -
    -
    - - {isoDate(day.date)} - -
    -
    - {num(day.day.km)} km · {efficiency(day.day.kwh, day.day.km)} -
    -
    -
    - {precise(day.day.kwh)} - kWh -
    -
    -
    -
    - - 0 ? `${num(day.day.km / day.day.hours)} km/h` : "—"} - /> - - -
    -
    -
    -
  • - ); - })} -
-
-
- ); -} diff --git a/web/src/app/globals.css b/web/src/app/globals.css deleted file mode 100644 index d10dbbc..0000000 --- a/web/src/app/globals.css +++ /dev/null @@ -1,430 +0,0 @@ -/* - * The car's palette, from app/src/main/res/values/colors.xml. - * - * Permanent OLED black with charcoal cards and muted gold/cyan accents — the same visual - * direction AGENTS.md pins for the head unit, so the phone and the dash read as one app. - * No light mode: this is looked at in a dark car as often as anywhere else. - */ -:root { - --bg: #000000; - --surface: #121212; - --raised: #1b1d1e; - --outline: #363b3d; - --text: #f4f3ef; - --muted: #bdc4c6; - --accent: #d8c39a; - --on-accent: #17140e; - --cyan: #a0e2e0; - --cyan-deep: #103436; - --gold-surface: #28241b; - --ok: #a4dcc1; - --warn: #e9ca96; - --error: #ffb4ab; - - --radius: 18px; - --gap: 12px; -} - -* { - box-sizing: border-box; -} - -html, -body { - margin: 0; - padding: 0; - background: var(--bg); - color: var(--text); - font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; - -webkit-font-smoothing: antialiased; - font-variant-numeric: tabular-nums; -} - -a { - color: inherit; - text-decoration: none; -} - -/* The phone frame. Wide windows get the same column rather than a stretched dashboard. */ -.shell { - max-width: 480px; - margin: 0 auto; - min-height: 100dvh; - padding: 20px 16px calc(84px + env(safe-area-inset-bottom)); - display: flex; - flex-direction: column; - gap: var(--gap); -} - -.header { - display: flex; - align-items: baseline; - justify-content: space-between; - margin-bottom: 4px; -} - -.title { - font-size: 26px; - font-weight: 650; - letter-spacing: -0.4px; - margin: 0; -} - -.subtitle { - color: var(--muted); - font-size: 13px; - margin: 2px 0 0; -} - -.card { - background: var(--surface); - border: 1px solid var(--outline); - border-radius: var(--radius); - padding: 16px; -} - -.card-title { - font-size: 12px; - letter-spacing: 1.1px; - text-transform: uppercase; - color: var(--muted); - margin: 0 0 12px; - font-weight: 600; -} - -/* Headline numbers: two per row, gold for energy/cost, cyan for distance/efficiency. */ -.tiles { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--gap); -} - -.tile { - background: var(--surface); - border: 1px solid var(--outline); - border-radius: var(--radius); - padding: 14px 16px; -} - -.tile.gold { - background: linear-gradient(160deg, var(--gold-surface), var(--surface) 70%); -} - -.tile.teal { - background: linear-gradient(160deg, var(--cyan-deep), var(--surface) 70%); -} - -.tile-label { - font-size: 11px; - letter-spacing: 0.8px; - text-transform: uppercase; - color: var(--muted); - margin: 0; -} - -.tile-value { - font-size: 24px; - font-weight: 650; - margin: 6px 0 0; - letter-spacing: -0.5px; -} - -.tile-unit { - font-size: 13px; - font-weight: 500; - color: var(--muted); - margin-left: 3px; -} - -.tile-note { - font-size: 11px; - color: var(--muted); - margin: 4px 0 0; -} - -/* Key/value rows inside a card. */ -.rows { - display: flex; - flex-direction: column; - gap: 10px; -} - -.row { - display: flex; - justify-content: space-between; - align-items: baseline; - gap: 12px; - font-size: 14px; -} - -.row + .row { - border-top: 1px solid var(--outline); - padding-top: 10px; -} - -.row-key { - color: var(--muted); -} - -.row-value { - font-weight: 600; -} - -.list { - display: flex; - flex-direction: column; - gap: 10px; - list-style: none; - margin: 0; - padding: 0; -} - -.session { - background: var(--surface); - border: 1px solid var(--outline); - border-radius: var(--radius); - padding: 14px 16px; -} - -.session summary { - cursor: pointer; - list-style: none; - display: flex; - justify-content: space-between; - align-items: center; - gap: 10px; -} - -.session summary::-webkit-details-marker { - display: none; -} - -.session-when { - font-size: 14px; - font-weight: 600; -} - -.session-meta { - font-size: 12px; - color: var(--muted); - margin-top: 3px; -} - -.session-energy { - text-align: right; - font-size: 18px; - font-weight: 650; - color: var(--accent); - white-space: nowrap; -} - -.session-cost { - font-size: 12px; - color: var(--muted); - font-weight: 500; -} - -.session-body { - margin-top: 14px; - padding-top: 14px; - border-top: 1px solid var(--outline); -} - -.chart { - width: 100%; - height: auto; - display: block; - overflow: visible; -} - -.legend { - display: flex; - gap: 14px; - font-size: 11px; - color: var(--muted); - margin-top: 8px; -} - -.swatch { - display: inline-block; - width: 9px; - height: 9px; - border-radius: 2px; - margin-right: 5px; - vertical-align: baseline; -} - -.empty { - color: var(--muted); - font-size: 14px; - text-align: center; - padding: 28px 12px; -} - -/* Bottom navigation — the car app falls back to the same pattern below 1000dp. */ -.nav { - position: fixed; - left: 0; - right: 0; - bottom: 0; - background: rgba(10, 10, 10, 0.92); - backdrop-filter: blur(14px); - border-top: 1px solid var(--outline); - padding: 8px 0 calc(8px + env(safe-area-inset-bottom)); -} - -.nav-inner { - max-width: 480px; - margin: 0 auto; - display: grid; - grid-template-columns: repeat(3, 1fr); -} - -.nav a { - text-align: center; - padding: 8px 4px; - font-size: 11px; - color: var(--muted); - font-weight: 600; - letter-spacing: 0.3px; -} - -.nav a[aria-current="page"] { - color: var(--accent); -} - -.nav-icon { - display: block; - font-size: 17px; - margin-bottom: 2px; -} - -/* Login */ -.login { - min-height: 100dvh; - display: flex; - align-items: center; - justify-content: center; - padding: 24px; -} - -.login-card { - width: 100%; - max-width: 360px; - background: var(--surface); - border: 1px solid var(--outline); - border-radius: var(--radius); - padding: 28px 24px; -} - -.field { - width: 100%; - background: var(--raised); - border: 1px solid var(--outline); - border-radius: 12px; - color: var(--text); - padding: 13px 14px; - font-size: 16px; - margin-top: 16px; -} - -.field:focus { - outline: none; - border-color: var(--accent); -} - -.button { - width: 100%; - margin-top: 14px; - background: var(--accent); - color: var(--on-accent); - border: none; - border-radius: 12px; - padding: 13px; - font-size: 15px; - font-weight: 650; - cursor: pointer; -} - -.error { - color: var(--error); - font-size: 13px; - margin: 14px 0 0; -} - -.signout { - font: inherit; - font-size: 12px; - color: var(--muted); - background: none; - border: 1px solid var(--outline); - border-radius: 999px; - padding: 5px 12px; - cursor: pointer; -} - -/* ---------- Consumption date filter ---------- */ - -.filter-form { - display: flex; - flex-direction: column; - gap: 10px; -} - -.filter-label { - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.5px; - color: var(--muted); -} - -.filter-row { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; -} - -.date-input { - width: auto; - min-width: 0; - flex: 1 1 150px; - margin-top: 0; - color-scheme: dark; -} - -.filter-button { - width: auto; - margin-top: 0; - padding: 12px 18px; - flex: 0 0 auto; -} - -.filter-clear { - font: inherit; - font-size: 13px; - color: var(--muted); - text-decoration: none; - border: 1px solid var(--outline); - border-radius: 12px; - padding: 11px 14px; - flex: 0 0 auto; -} - -.filter-clear:active { - border-color: var(--accent); -} - -/* Highlighted selected day in the day-by-day list */ -.session.selected { - border-color: var(--accent); -} - -.day-link { - color: var(--text); - text-decoration: none; -} - -.day-link:active { - color: var(--accent); -} diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx deleted file mode 100644 index a3a2aeb..0000000 --- a/web/src/app/layout.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { Metadata, Viewport } from "next"; -import "./globals.css"; - -export const metadata: Metadata = { - title: "FatihsMG4", - description: "MG4 tüketim ve şarj geçmişi", -}; - -export const viewport: Viewport = { - themeColor: "#000000", - width: "device-width", - initialScale: 1, - // The dashboard is pinned to a phone-width column; zooming it is still the reader's call. - maximumScale: 5, -}; - -export default function RootLayout({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx deleted file mode 100644 index 459543d..0000000 --- a/web/src/app/login/page.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { redirect } from "next/navigation"; -import { hasSession } from "@/lib/auth"; - -export const dynamic = "force-dynamic"; - -export default async function LoginPage({ - searchParams, -}: { - searchParams: Promise<{ error?: string }>; -}) { - if (await hasSession()) redirect("/"); - const { error } = await searchParams; - return ( -
-
-

FatihsMG4

-

Tüketim ve şarj geçmişi

- {/* - A hidden username so password managers will offer to save this. - Most of them ignore a lone password field — they key a saved entry on - (origin, username), and with nothing to key on they stay quiet. There is only - ever one account here, so the value is fixed and the server never reads it. - */} - - - - {error &&

Parola hatalı.

} -
-
- ); -} diff --git a/web/src/app/logout/route.ts b/web/src/app/logout/route.ts deleted file mode 100644 index 8b016f6..0000000 --- a/web/src/app/logout/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextResponse } from "next/server"; -import { SESSION_COOKIE } from "@/lib/session"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * POST only, deliberately. - * - * This used to answer GET behind a ``, so Next's viewport prefetch called it on - * every render and cleared the cookie before the reader had clicked anything — which - * presented as the session expiring on every navigation. Anything that changes state has - * to be unreachable by a prefetch, a crawler or a preloading browser, and that means not - * answering GET. - */ -export async function POST(request: Request) { - const response = NextResponse.redirect(new URL("/login", request.url), { status: 303 }); - // Cleared on the response itself rather than through the cookies() store: a freshly - // constructed redirect does not reliably carry what that store recorded. - response.cookies.set(SESSION_COOKIE, "", { path: "/", maxAge: 0 }); - return response; -} diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx deleted file mode 100644 index a6cae0f..0000000 --- a/web/src/app/page.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { requireSession } from "@/lib/auth"; -import { loadOverview, sumCharging, sumDays } from "@/lib/data"; -import { dateTime, duration, efficiency, money, num, precise } from "@/lib/format"; -import { DailyEnergyChart } from "@/components/charts"; -import { Row, Shell, Tile } from "@/components/shell"; - -export const dynamic = "force-dynamic"; - -export default async function OverviewPage() { - await requireSession(); - const { installationId, installationCount, days, sessions } = await loadOverview(30, 200); - - if (!installationId) { - return ( - -
-

- Henüz veri yok. Araç ilk günlük kaydı gönderdiğinde burada görünecek. -

-
-
- ); - } - - const charge = sumCharging(sessions); - const recent = sumDays(days); - const lifetime = days.length > 0 ? days[days.length - 1].lifetime : null; - const soh = [...days].reverse().find((day) => day.sohPercent !== undefined)?.sohPercent; - const lastSession = sessions[0]; - - return ( - 1 ? `${installationCount} araçtan en günceli` : "MG4"} - > -
- - 0 ? `${money(charge.cost / charge.gridEnergyKwh)}/kWh ort.` : undefined} - tone="gold" - /> - - -
- -
-

Son 30 gün

- ({ date: day.date, kwh: day.day.kwh, km: day.day.km }))} /> -
- - {lastSession && ( -
-

Son şarj

-
- - - - - - -
-
- )} - -
-

Batarya ve toplamlar

-
- - - 0 ? `${num(charge.peakKw)} kW` : "—"} /> - 0 ? `%${num(charge.socGained)}` : "—"} /> - -
-
-
- ); -} diff --git a/web/src/app/v1/charging-sessions/[sessionId]/route.ts b/web/src/app/v1/charging-sessions/[sessionId]/route.ts deleted file mode 100644 index d36ff88..0000000 --- a/web/src/app/v1/charging-sessions/[sessionId]/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { NextResponse } from "next/server"; -import { handle, parseJson, requireIngest } from "@/lib/api"; -import { chargingCollection, ensureIndexes } from "@/lib/mongo"; -import { validateChargingSession } from "@/lib/validate"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -export async function PUT(request: Request, context: { params: Promise<{ sessionId: string }> }) { - const denied = requireIngest(request); - 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 }, - ); - return new NextResponse(null, { status: 204 }); - }); -} diff --git a/web/src/app/v1/consumption/daily/route.ts b/web/src/app/v1/consumption/daily/route.ts deleted file mode 100644 index 7397ba6..0000000 --- a/web/src/app/v1/consumption/daily/route.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { NextResponse } from "next/server"; -import { handle, parseJson, requireIngest } from "@/lib/api"; -import { consumptionCollection, ensureIndexes } from "@/lib/mongo"; -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. - */ -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 = { 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 }); - }); -} - diff --git a/web/src/app/v1/consumption/history/[installationId]/route.ts b/web/src/app/v1/consumption/history/[installationId]/route.ts deleted file mode 100644 index e44811f..0000000 --- a/web/src/app/v1/consumption/history/[installationId]/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { NextResponse } from "next/server"; -import { handle, requireIngest } from "@/lib/api"; -import { consumptionCollection } from "@/lib/mongo"; -import { validateUuid } from "@/lib/validate"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * Ingest-token-authenticated history read for the head-unit app. - * - * The dashboard history endpoints under /v1/history use the dashboard password (a - * browser-only credential). The head unit only holds the ingest token, which was - * write-only by design — but the car's consumption UI now reads back its own uploaded - * totals so an APK wipe does not blank the display. The installationId is a fixed UUID - * baked into the APK, so an extracted token plus a guessed installationId is the only - * exposure, and the data revealed is the owner's own consumption history. - */ -export async function GET(request: Request, context: { params: Promise<{ installationId: string }> }) { - const denied = requireIngest(request); - 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 }, - ); - return NextResponse.json({ records, lifetime }); - }); -} diff --git a/web/src/app/v1/health/route.ts b/web/src/app/v1/health/route.ts deleted file mode 100644 index 4efb85e..0000000 --- a/web/src/app/v1/health/route.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { NextResponse } from "next/server"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** Unauthenticated on purpose: it reveals nothing and the car has no other probe. */ -export function GET() { - return NextResponse.json({ status: "ok" }); -} diff --git a/web/src/app/v1/history/[installationId]/charging/route.ts b/web/src/app/v1/history/[installationId]/charging/route.ts deleted file mode 100644 index 73dd795..0000000 --- a/web/src/app/v1/history/[installationId]/charging/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextResponse } from "next/server"; -import { handle, requireReader } from "@/lib/api"; -import { chargingCollection } from "@/lib/mongo"; -import { validateUuid } from "@/lib/validate"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -export async function GET(request: Request, context: { params: Promise<{ installationId: string }> }) { - const denied = await requireReader(request); - 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(); - return NextResponse.json({ records }); - }); -} diff --git a/web/src/app/v1/history/[installationId]/consumption/route.ts b/web/src/app/v1/history/[installationId]/consumption/route.ts deleted file mode 100644 index 19d108c..0000000 --- a/web/src/app/v1/history/[installationId]/consumption/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextResponse } from "next/server"; -import { handle, requireReader } from "@/lib/api"; -import { consumptionCollection } from "@/lib/mongo"; -import { validateUuid } from "@/lib/validate"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -export async function GET(request: Request, context: { params: Promise<{ installationId: string }> }) { - const denied = await requireReader(request); - 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(); - return NextResponse.json({ records }); - }); -} diff --git a/web/src/components/charts.tsx b/web/src/components/charts.tsx deleted file mode 100644 index b02c6be..0000000 --- a/web/src/components/charts.tsx +++ /dev/null @@ -1,267 +0,0 @@ -/* - * Hand-rolled SVG charts. - * - * A charting library would be the larger part of the bundle for three small plots, and - * these are server-rendered: no hydration, no client JavaScript, and they draw correctly - * before anything loads. Each one states its own units in the legend rather than relying - * on a tooltip, because a tooltip needs a pointer and this is read on a phone. - */ - -const GOLD = "#d8c39a"; -const CYAN = "#a0e2e0"; -const GRID = "#363b3d"; -const MUTED = "#bdc4c6"; - -interface Plot { - width: number; - height: number; - padTop: number; - padRight: number; - padBottom: number; - padLeft: number; -} - -const PLOT: Plot = { width: 320, height: 150, padTop: 10, padRight: 8, padBottom: 20, padLeft: 34 }; - -function innerWidth(plot: Plot) { - return plot.width - plot.padLeft - plot.padRight; -} -function innerHeight(plot: Plot) { - return plot.height - plot.padTop - plot.padBottom; -} - -/** A rounded axis maximum, so the gridline labels are readable numbers. */ -function niceMax(value: number): number { - if (!(value > 0)) return 1; - const magnitude = 10 ** Math.floor(Math.log10(value)); - const normalised = value / magnitude; - const step = normalised <= 1 ? 1 : normalised <= 2 ? 2 : normalised <= 5 ? 5 : 10; - return step * magnitude; -} - -function Grid({ plot, max, format }: { plot: Plot; max: number; format: (value: number) => string }) { - const lines = [0, 0.5, 1]; - return ( - - {lines.map((fraction) => { - const y = plot.padTop + innerHeight(plot) * (1 - fraction); - return ( - - - - {format(max * fraction)} - - - ); - })} - - ); -} - -export interface DayDatum { - date: string; - kwh: number; - km: number; -} - -/** - * Daily energy as bars. Days with no record are absent rather than drawn as zero — the - * car was not driven, which is not the same as a day of zero consumption, and the same - * distinction the telemetry side makes between a failed read and a zero. - */ -export function DailyEnergyChart({ days }: { days: DayDatum[] }) { - if (days.length === 0) return

Henüz günlük kayıt yok

; - const plot = PLOT; - const max = niceMax(Math.max(...days.map((day) => day.kwh))); - const slot = innerWidth(plot) / days.length; - const barWidth = Math.max(1.5, Math.min(14, slot * 0.62)); - return ( - <> - - value.toFixed(0)} /> - {days.map((day, index) => { - const height = (day.kwh / max) * innerHeight(plot); - const x = plot.padLeft + slot * (index + 0.5) - barWidth / 2; - return ( - 0 ? 1 : 0)} - rx={2} - fill={GOLD} - opacity={0.92} - > - {`${day.date} — ${day.kwh.toFixed(2)} kWh, ${day.km.toFixed(1)} km`} - - ); - })} - - {shortDate(days[0].date)} - - - {shortDate(days[days.length - 1].date)} - - -
- - - Günlük enerji (kWh) - -
- - ); -} - -/** Efficiency over time. Days under 1 km are dropped: the ratio is meaningless there. */ -export function EfficiencyChart({ days }: { days: DayDatum[] }) { - const points = days - .filter((day) => day.km >= 1 && day.kwh > 0) - .map((day) => ({ date: day.date, value: (day.kwh * 100) / day.km })); - if (points.length < 2) return

Grafik için yeterli gün yok

; - const plot = PLOT; - const max = niceMax(Math.max(...points.map((point) => point.value))); - const step = innerWidth(plot) / Math.max(1, points.length - 1); - const path = points - .map((point, index) => { - const x = plot.padLeft + step * index; - const y = plot.padTop + innerHeight(plot) * (1 - point.value / max); - return `${index === 0 ? "M" : "L"}${x.toFixed(2)} ${y.toFixed(2)}`; - }) - .join(" "); - const average = points.reduce((total, point) => total + point.value, 0) / points.length; - const averageY = plot.padTop + innerHeight(plot) * (1 - average / max); - return ( - <> - - value.toFixed(0)} /> - - - {points.map((point, index) => ( - - {`${point.date} — ${point.value.toFixed(1)} kWh/100 km`} - - ))} - -
- - - kWh/100 km - - - - Ortalama {average.toFixed(1)} - -
- - ); -} - -export interface CurveDatum { - timestamp: string; - powerKw: number; - socPercent?: number; -} - -/** - * Charge curve: power against time, with SOC on its own 0–100 scale. - * - * Two quantities on one plot because the shape of a DC charge is the relationship between - * them — the taper is only legible next to the percentage that caused it. - */ -export function ChargeCurveChart({ curve }: { curve: CurveDatum[] }) { - if (curve.length < 2) return

Bu seans için eğri kaydı yok

; - const plot = { ...PLOT, padRight: 26 }; - const times = curve.map((point) => new Date(point.timestamp).getTime()); - const start = times[0]; - const span = Math.max(1, times[times.length - 1] - start); - const maxPower = niceMax(Math.max(...curve.map((point) => point.powerKw))); - - const x = (time: number) => plot.padLeft + (innerWidth(plot) * (time - start)) / span; - const yPower = (kw: number) => plot.padTop + innerHeight(plot) * (1 - kw / maxPower); - const ySoc = (percent: number) => plot.padTop + innerHeight(plot) * (1 - percent / 100); - - const powerPath = curve - .map((point, index) => `${index === 0 ? "M" : "L"}${x(times[index]).toFixed(2)} ${yPower(point.powerKw).toFixed(2)}`) - .join(" "); - const areaPath = `${powerPath} L${x(times[times.length - 1]).toFixed(2)} ${(plot.padTop + innerHeight(plot)).toFixed(2)} L${x(start).toFixed(2)} ${(plot.padTop + innerHeight(plot)).toFixed(2)} Z`; - - const socPoints = curve - .map((point, index) => ({ point, index })) - .filter((entry) => typeof entry.point.socPercent === "number"); - const socPath = socPoints - .map((entry, order) => - `${order === 0 ? "M" : "L"}${x(times[entry.index]).toFixed(2)} ${ySoc(entry.point.socPercent as number).toFixed(2)}`) - .join(" "); - - return ( - <> - - value.toFixed(0)} /> - - - - - - - - - {socPath && } - - 100% - - - 0% - - - 0 dk - - - {Math.round(span / 60000)} dk - - -
- - - Güç (kW) - - - - Doluluk (%) - -
- - ); -} - -function shortDate(iso: string): string { - const [, month, day] = iso.split("-"); - return `${day}.${month}`; -} diff --git a/web/src/components/shell.tsx b/web/src/components/shell.tsx deleted file mode 100644 index a9320aa..0000000 --- a/web/src/components/shell.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import Link from "next/link"; -import type { ReactNode } from "react"; - -const TABS = [ - { href: "/", label: "Özet", icon: "◈" }, - { href: "/consumption", label: "Tüketim", icon: "▤" }, - { href: "/charging", label: "Şarj", icon: "⚡" }, -] as const; - -export type Tab = (typeof TABS)[number]["href"]; - -/** - * The phone frame and the bottom tab bar. - * - * `active` is passed in rather than read from the pathname so every page stays a server - * component — the whole dashboard renders without shipping a line of client JavaScript. - */ -export function Shell({ - active, - title, - subtitle, - children, -}: { - active: Tab; - title: string; - subtitle?: string; - children: ReactNode; -}) { - return ( - <> -
-
-
-

{title}

- {subtitle &&

{subtitle}

} -
- {/* - A form, not a link. Next prefetches links that are in the viewport, and this - one is in every page header — as a GET route it was fetched on render and - signed the reader out before they touched it, which looked like the session - expiring on every navigation. Nothing with a side effect may answer GET. - */} -
- -
-
- {children} -
- - - ); -} - -export function Tile({ - label, - value, - unit, - note, - tone, -}: { - label: string; - value: string; - unit?: string; - note?: string; - tone?: "gold" | "teal"; -}) { - return ( -
-

{label}

-

- {value} - {unit && {unit}} -

- {note &&

{note}

} -
- ); -} - -export function Row({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ); -} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts deleted file mode 100644 index 327e63b..0000000 --- a/web/src/lib/api.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { NextResponse } from "next/server"; -import { hasIngestToken, hasSession, checkDashboardPassword } from "./auth"; -import { ValidationError } from "./validate"; - -/** Every endpoint touches MongoDB, which rules out the edge runtime. */ -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -export function unauthorized(): NextResponse { - return NextResponse.json({ error: "unauthorized" }, { status: 401 }); -} - -/** - * Wraps a handler so a validation failure is a 400 and anything else is a 500 whose - * detail stays in the server log. An error message is allowed to name a bad field; it is - * never allowed to carry a connection string. - */ -export async function handle(work: () => Promise): Promise { - try { - return await work(); - } catch (error) { - if (error instanceof ValidationError) { - return NextResponse.json({ error: error.message }, { status: 400 }); - } - console.error("Request failed", error); - return NextResponse.json({ error: "internal server error" }, { status: 500 }); - } -} - -export function requireIngest(request: Request): NextResponse | null { - return hasIngestToken(request) ? null : unauthorized(); -} - -/** - * Read access: the dashboard's own session cookie, or the dashboard password as a bearer - * token so a native client can read the same history without a browser. - */ -export async function requireReader(request: Request): Promise { - if (await hasSession()) return null; - const header = request.headers.get("authorization") ?? ""; - if (header.startsWith("Bearer ") && checkDashboardPassword(header.slice(7))) return null; - return unauthorized(); -} - -export async function parseJson(request: Request): Promise { - const raw = await request.text(); - if (raw.length > 512 * 1024) throw new ValidationError("body is too large"); - try { - return JSON.parse(raw); - } catch { - throw new ValidationError("body is not valid JSON"); - } -} diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts deleted file mode 100644 index c1355b5..0000000 --- a/web/src/lib/auth.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * The Next-aware half of authentication. Everything cryptographic lives in - * {@link ./session}, which imports nothing from Next and is therefore unit-testable - * without a request, a build, or a running server. - */ -import { cookies } from "next/headers"; -import { redirect } from "next/navigation"; -import { SESSION_COOKIE, isValidSessionValue } from "./session"; - -export * from "./session"; - -export async function hasSession(): Promise { - const store = await cookies(); - return isValidSessionValue(store.get(SESSION_COOKIE)?.value); -} - -/** For pages: send an unauthenticated visitor to the password screen. */ -export async function requireSession(): Promise { - if (!(await hasSession())) redirect("/login"); -} diff --git a/web/src/lib/data.ts b/web/src/lib/data.ts deleted file mode 100644 index b23bf5b..0000000 --- a/web/src/lib/data.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { chargingCollection, consumptionCollection, type ChargingSession, type DailyConsumption } from "./mongo"; - -/** - * Reads for the dashboard. - * - * Every query is scoped to one installation id. There is one car today, but the id is in - * the schema and in the unique indexes, so scoping now costs nothing and means a second - * head unit does not silently merge its history into the first one's charts. - */ - -export interface Overview { - installationId: string | null; - installationCount: number; - days: DailyConsumption[]; - sessions: ChargingSession[]; -} - -/** 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 }> { - const collection = await consumptionCollection(); - const ids = await collection.distinct("installationId"); - 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 { - const { id, count } = await latestInstallationId(); - if (!id) return { installationId: null, installationCount: 0, days: [], sessions: [] }; - const [consumption, charging] = await Promise.all([consumptionCollection(), chargingCollection()]); - const [days, sessions] = await Promise.all([ - consumption.find({ installationId: id }, { projection: { _id: 0 } }) - .sort({ date: -1 }).limit(dayLimit).toArray(), - 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 - // the recent end rather than the beginning of history. - return { installationId: id, installationCount: count, days: days.reverse(), sessions }; -} - -export interface ChargeTotals { - energyKwh: number; - gridEnergyKwh: number; - cost: number; - sessions: number; - seconds: number; - socGained: number; - peakKw: number; -} - -export function sumCharging(sessions: ChargingSession[]): ChargeTotals { - return sessions.reduce( - (total, session) => ({ - energyKwh: total.energyKwh + session.energyKwh, - gridEnergyKwh: total.gridEnergyKwh + (session.gridEnergyKwh ?? session.energyKwh * 1.1), - cost: total.cost + session.totalCost, - sessions: total.sessions + 1, - seconds: total.seconds + session.durationSeconds, - socGained: - total.socGained + - (session.startSocPercent !== undefined && session.endSocPercent !== undefined - ? Math.max(0, session.endSocPercent - session.startSocPercent) - : 0), - peakKw: Math.max(total.peakKw, ...session.curve.map((point) => point.powerKw), 0), - }), - { energyKwh: 0, gridEnergyKwh: 0, cost: 0, sessions: 0, seconds: 0, socGained: 0, peakKw: 0 }, - ); -} - -export function sumDays(days: DailyConsumption[]) { - return days.reduce( - (total, record) => ({ - km: total.km + record.day.km, - kwh: total.kwh + record.day.kwh, - hours: total.hours + record.day.hours, - }), - { km: 0, kwh: 0, hours: 0 }, - ); -} diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts deleted file mode 100644 index 19869fc..0000000 --- a/web/src/lib/format.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** Turkish formatting for every user-facing number and date. */ - -const NUMBER = new Intl.NumberFormat("tr-TR", { maximumFractionDigits: 1 }); -const PRECISE = new Intl.NumberFormat("tr-TR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); -const CURRENCY = new Intl.NumberFormat("tr-TR", { style: "currency", currency: "TRY", maximumFractionDigits: 2 }); -const DATE = new Intl.DateTimeFormat("tr-TR", { day: "numeric", month: "long", year: "numeric" }); -const DATE_TIME = new Intl.DateTimeFormat("tr-TR", { - day: "numeric", - month: "short", - hour: "2-digit", - minute: "2-digit", -}); - -export function num(value: number): string { - return NUMBER.format(value); -} - -export function precise(value: number): string { - return PRECISE.format(value); -} - -export function money(value: number): string { - return CURRENCY.format(value); -} - -export function isoDate(iso: string): string { - const [year, month, day] = iso.split("-").map(Number); - return DATE.format(new Date(Date.UTC(year, month - 1, day))); -} - -export function dateTime(value: Date | string): string { - return DATE_TIME.format(new Date(value)); -} - -/** `2 sa 05 dk` — the same shape the car's charging pane uses. */ -export function duration(seconds: number): string { - const total = Math.max(0, Math.round(seconds)); - const hours = Math.floor(total / 3600); - const minutes = Math.floor((total % 3600) / 60); - if (hours === 0) return `${minutes} dk`; - return `${hours} sa ${String(minutes).padStart(2, "0")} dk`; -} - -/** Energy per 100 km, or a dash — never a ratio computed from a distance of nothing. */ -export function efficiency(kwh: number, km: number): string { - if (km < 1) return "—"; - return `${num((kwh * 100) / km)} kWh/100 km`; -} diff --git a/web/src/lib/mongo.ts b/web/src/lib/mongo.ts deleted file mode 100644 index 508dcc6..0000000 --- a/web/src/lib/mongo.ts +++ /dev/null @@ -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 }; - -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 { - 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 { - const client = await clientPromise(); - return client.db(process.env.MONGODB_DATABASE || "fatihsmg4"); -} - -export async function consumptionCollection(): Promise> { - return (await db()).collection("daily_consumption"); -} - -export async function chargingCollection(): Promise> { - return (await db()).collection("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 { - 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; -} diff --git a/web/src/lib/session.ts b/web/src/lib/session.ts deleted file mode 100644 index db335e2..0000000 --- a/web/src/lib/session.ts +++ /dev/null @@ -1,80 +0,0 @@ -import crypto from "node:crypto"; - -/** - * Two separate credentials, deliberately. - * - * The car holds {@link INGEST_TOKEN} and can only write. It is baked into a build that - * lives on a head unit and is published as an APK, so it must be assumed extractable — - * and an extracted write token buys nothing but the ability to post junk under an - * installation id the attacker would have to guess. - * - * Reading the history needs the dashboard password, which never leaves the browser's - * cookie jar and is never compiled into anything. - */ -export const SESSION_COOKIE = "fatihsmg4_session"; -const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30; - -/** Constant-time compare that does not leak length through an early return. */ -function sameSecret(supplied: string, expected: string): boolean { - const a = crypto.createHash("sha256").update(supplied).digest(); - const b = crypto.createHash("sha256").update(expected).digest(); - return crypto.timingSafeEqual(a, b); -} - -function requireEnv(name: string): string { - const value = process.env[name]; - if (!value) throw new Error(`${name} is not configured`); - return value; -} - -/** Does this request carry the car's write token? */ -export function hasIngestToken(request: Request): boolean { - const expected = process.env.INGEST_TOKEN; - if (!expected || expected.length < 24) return false; - const header = request.headers.get("authorization") ?? ""; - if (!header.startsWith("Bearer ")) return false; - return sameSecret(header.slice(7), expected); -} - -export function checkDashboardPassword(supplied: string): boolean { - const expected = process.env.DASHBOARD_PASSWORD; - if (!expected || expected.length < 8) return false; - return sameSecret(supplied, expected); -} - -/** - * `.` — no server-side session store, which a serverless deployment has - * nowhere to keep anyway. The expiry is inside the signed payload, so a browser that - * keeps the cookie past its date cannot use it. - */ -export function issueSessionValue(): string { - const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS; - return `${expiresAt}.${sign(String(expiresAt))}`; -} - -function sign(payload: string): string { - return crypto.createHmac("sha256", requireEnv("SESSION_SECRET")).update(payload).digest("hex"); -} - -export function isValidSessionValue(value: string | undefined): boolean { - if (!value) return false; - const separator = value.lastIndexOf("."); - if (separator <= 0) return false; - const expiresAt = Number(value.slice(0, separator)); - if (!Number.isSafeInteger(expiresAt) || expiresAt * 1000 < Date.now()) return false; - try { - const expected = Buffer.from(sign(String(expiresAt)), "hex"); - const supplied = Buffer.from(value.slice(separator + 1), "hex"); - return expected.length === supplied.length && crypto.timingSafeEqual(expected, supplied); - } catch { - return false; - } -} - -export const sessionCookieOptions = { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - path: "/", - maxAge: SESSION_TTL_SECONDS, -} as const; diff --git a/web/src/lib/validate.ts b/web/src/lib/validate.ts deleted file mode 100644 index 1184fc0..0000000 --- a/web/src/lib/validate.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Request validation for the ingest endpoints — a direct port of the Express service's - * rules, kept pure so it can be unit-tested without a database or a request. - * - * The car is the only writer and it is trusted, but a bad field here is written once and - * read forever. Every number is bounded; an out-of-range value is rejected rather than - * stored and quietly plotted. - */ -import type { ChargingSession, CurvePoint, DailyConsumption, Totals } from "./mongo"; - -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 ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; - -export type ConsumptionInput = Omit; -export type ChargingInput = Omit; - -export function validateConsumption(body: unknown): ConsumptionInput { - const record = asObject(body); - const date = record.date; - if (typeof date !== "string" || !ISO_DATE.test(date)) throw new ValidationError("date is invalid"); - const sohPercent = record.sohPercent; - const lifetime = record.lifetime; - return { - installationId: validateUuid(record.installationId, "installationId"), - date, - recordedAt: validateDate(record.recordedAt, "recordedAt"), - 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") }), - }; -} - -export function validateChargingSession(body: unknown, pathSessionId: string): ChargingInput { - const record = asObject(body); - const sessionId = validateUuid(record.sessionId, "sessionId"); - if (sessionId !== pathSessionId) throw new ValidationError("sessionId does not match path"); - if (!Array.isArray(record.curve) || record.curve.length > 720) { - throw new ValidationError("curve is invalid"); - } - const curve: CurvePoint[] = record.curve.map((entry) => { - const point = asObject(entry); - const socPercent = point.socPercent; - return { - timestamp: validateDate(point.timestampMs, "curve.timestampMs"), - powerKw: bounded(point.powerKw, 0, 1000, "curve.powerKw"), - ...(socPercent === undefined ? {} : { socPercent: bounded(socPercent, 0, 100, "curve.socPercent") }), - }; - }); - const startSocPercent = record.startSocPercent; - const endSocPercent = record.endSocPercent; - const isDemo = record.isDemo; - const energyKwh = bounded(record.energyKwh, 0, 1000, "energyKwh"); - const pricePerKwh = bounded(record.pricePerKwh, 0, 1_000_000, "pricePerKwh"); - const chargingLossPercent = 10; - const gridEnergyKwh = energyKwh * 1.1; - return { - installationId: validateUuid(record.installationId, "installationId"), - sessionId, - startedAt: validateDate(record.startedAt, "startedAt"), - endedAt: validateDate(record.endedAt, "endedAt"), - durationSeconds: bounded(record.durationSeconds, 0, 604800, "durationSeconds"), - energyKwh, - chargingLossPercent, - gridEnergyKwh, - pricePerKwh, - totalCost: gridEnergyKwh * pricePerKwh, - ...(isDemo === true ? { isDemo: true } : {}), - ...(startSocPercent === undefined ? {} : { startSocPercent: bounded(startSocPercent, 0, 100, "startSocPercent") }), - ...(endSocPercent === undefined ? {} : { endSocPercent: bounded(endSocPercent, 0, 100, "endSocPercent") }), - curve, - }; -} - -export function validateUuid(value: unknown, name: string): string { - if (typeof value !== "string" || !UUID.test(value)) throw new ValidationError(`${name} is invalid`); - return value; -} - -function asObject(value: unknown): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new ValidationError("body is required"); - } - return value as Record; -} - -/** Epoch milliseconds on the wire; a Date in the database. */ -function validateDate(value: unknown, name: string): Date { - if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { - throw new ValidationError(`${name} is invalid`); - } - return new Date(value); -} - -function bounded(value: unknown, min: number, max: number, name: string): number { - if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) { - throw new ValidationError(`${name} is invalid`); - } - return value; -} - -function validateTotals(value: unknown, name: string): Totals { - const record = asObject(value); - const result = {} as Totals; - for (const field of ["km", "kwh", "hours", "socDrop"] as const) { - const number = record[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; -} diff --git a/web/test/session.test.ts b/web/test/session.test.ts deleted file mode 100644 index 6d14b6f..0000000 --- a/web/test/session.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -process.env.SESSION_SECRET = "0123456789abcdef0123456789abcdef"; -process.env.DASHBOARD_PASSWORD = "correct-horse-battery"; -process.env.INGEST_TOKEN = "a".repeat(64); - -const { checkDashboardPassword, hasIngestToken, isValidSessionValue, issueSessionValue } = - await import("../src/lib/session.ts"); - -test("a freshly issued session validates and a tampered one does not", () => { - const value = issueSessionValue(); - assert.equal(isValidSessionValue(value), true); - const [expiry, signature] = value.split("."); - // Moving the expiry out invalidates the signature, so a cookie cannot extend itself. - assert.equal(isValidSessionValue(`${Number(expiry) + 86_400}.${signature}`), false); - assert.equal(isValidSessionValue(`${expiry}.${"0".repeat(signature.length)}`), false); - assert.equal(isValidSessionValue(undefined), false); - assert.equal(isValidSessionValue("garbage"), false); -}); - -test("an expired session is refused even with a valid signature", async () => { - const past = Math.floor(Date.now() / 1000) - 10; - // Signed by the same secret, so only the embedded expiry rejects it. - const crypto = await import("node:crypto"); - const signature = crypto.createHmac("sha256", process.env.SESSION_SECRET as string) - .update(String(past)).digest("hex"); - assert.equal(isValidSessionValue(`${past}.${signature}`), false); -}); - -test("the dashboard password is exact", () => { - assert.equal(checkDashboardPassword("correct-horse-battery"), true); - assert.equal(checkDashboardPassword("correct-horse-batter"), false); - assert.equal(checkDashboardPassword(""), false); -}); - -test("the ingest token is only accepted as a bearer header", () => { - const token = "a".repeat(64); - const bearer = (value: string) => new Request("https://x/v1", { headers: { authorization: value } }); - assert.equal(hasIngestToken(bearer(`Bearer ${token}`)), true); - assert.equal(hasIngestToken(bearer(token)), false); - assert.equal(hasIngestToken(bearer(`Bearer ${"b".repeat(64)}`)), false); - assert.equal(hasIngestToken(new Request("https://x/v1")), false); -}); diff --git a/web/test/validate.test.ts b/web/test/validate.test.ts deleted file mode 100644 index eeef907..0000000 --- a/web/test/validate.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { ValidationError, validateChargingSession, validateConsumption } from "../src/lib/validate.ts"; - -const INSTALLATION = "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b"; -const SESSION = "11111111-2222-4333-8444-555555555555"; - -const day = { km: 42.5, kwh: 7.25, hours: 0.9, socDrop: 11.5 }; - -test("accepts a well-formed daily record", () => { - const record = validateConsumption({ - installationId: INSTALLATION, - date: "2026-10-07", - recordedAt: 1_760_000_000_000, - day, - lifetime: { km: 12000, kwh: 2100, hours: 240, socDrop: 3400 }, - sohPercent: 97.5, - }); - assert.equal(record.date, "2026-10-07"); - assert.equal(record.day.kwh, 7.25); - assert.ok(record.recordedAt instanceof Date); -}); - -test("a missing SOH is omitted rather than defaulted", () => { - // The car omits a field it could not read. Writing 0 here would be a battery at - // nothing, which is the same class of lie the telemetry side refuses to send. - const record = validateConsumption({ - installationId: INSTALLATION, - date: "2026-10-07", - recordedAt: 1, - day, - lifetime: day, - }); - assert.equal("sohPercent" in record, false); -}); - -test("rejects malformed identifiers, dates and out-of-range numbers", () => { - const base = { installationId: INSTALLATION, date: "2026-10-07", recordedAt: 1, day, lifetime: day }; - assert.throws(() => validateConsumption({ ...base, installationId: "nope" }), ValidationError); - assert.throws(() => validateConsumption({ ...base, date: "07.10.2026" }), ValidationError); - assert.throws(() => validateConsumption({ ...base, sohPercent: 140 }), ValidationError); - assert.throws(() => validateConsumption({ ...base, day: { ...day, kwh: "7" } }), ValidationError); - assert.throws(() => validateConsumption({ ...base, recordedAt: -1 }), ValidationError); - assert.throws(() => validateConsumption(null), ValidationError); -}); - -test("a charging session must match the id in its path", () => { - const body = { - installationId: INSTALLATION, - sessionId: SESSION, - startedAt: 1_760_000_000_000, - endedAt: 1_760_002_000_000, - durationSeconds: 1800, - energyKwh: 31.4, - chargingLossPercent: 10, - gridEnergyKwh: 34.54, - pricePerKwh: 7.5, - totalCost: 235.5, - isDemo: true, - startSocPercent: 18, - endSocPercent: 72, - curve: [{ timestampMs: 1_760_000_060_000, powerKw: 84.2, socPercent: 20 }], - }; - const record = validateChargingSession(body, SESSION); - assert.equal(record.curve.length, 1); - assert.equal(record.curve[0].powerKw, 84.2); - assert.equal(record.chargingLossPercent, 10); - assert.equal(record.gridEnergyKwh, 34.54); - assert.equal(record.totalCost, 259.05); - assert.equal(record.isDemo, true); - assert.throws(() => validateChargingSession(body, "11111111-2222-4333-8444-555555555556"), ValidationError); -}); - -test("refuses a curve longer than the car can produce", () => { - const curve = Array.from({ length: 721 }, (_unused, index) => ({ - timestampMs: 1_760_000_000_000 + index * 60_000, - powerKw: 50, - })); - assert.throws( - () => - validateChargingSession( - { - installationId: INSTALLATION, - sessionId: SESSION, - startedAt: 1, - endedAt: 2, - durationSeconds: 1, - energyKwh: 1, - chargingLossPercent: 10, - gridEnergyKwh: 1.1, - pricePerKwh: 1, - totalCost: 1, - curve, - }, - SESSION, - ), - ValidationError, - ); -}); diff --git a/web/tsconfig.json b/web/tsconfig.json deleted file mode 100644 index 90069c0..0000000 --- a/web/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["dom", "dom.iterable", "ES2022"], - "allowJs": false, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [{ "name": "next" }], - "paths": { "@/*": ["./src/*"] } - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] -}