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/flake.nix b/flake.nix index 88d38f0..a371adf 100644 --- a/flake.nix +++ b/flake.nix @@ -112,7 +112,7 @@ in { default = pkgs.mkShell { - packages = [ jdk android.androidsdk buildCar pkgs.nodejs_22 pkgs.influxdb2-server pkgs.influxdb2-cli ]; + packages = [ jdk android.androidsdk buildCar pkgs.go pkgs.gopls pkgs.govulncheck pkgs.influxdb2-server pkgs.influxdb2-cli ]; ANDROID_HOME = sdk; ANDROID_SDK_ROOT = sdk; diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..6f1931b --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,4 @@ +* +!go.mod +!go.sum +!*.go diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..c373c44 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,16 @@ +# The car server: one static Go binary on distroless, running as non-root. +# Configuration comes from the environment at runtime; nothing secret is baked in. + +FROM docker.io/library/golang:1.26.9-alpine AS build +WORKDIR /src +ENV CGO_ENABLED=0 GOFLAGS=-trimpath GOTOOLCHAIN=local +COPY go.mod go.sum ./ +RUN go mod download && go mod verify +COPY *.go ./ +RUN go vet ./... && go test ./... && go build -ldflags="-s -w" -o /out/server . + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/server /server +USER 65532:65532 +EXPOSE 8080 +ENTRYPOINT ["/server"] diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..8dfba01 --- /dev/null +++ b/server/README.md @@ -0,0 +1,77 @@ +# Nils' MG4 — car server + +A small Go service at car.stinnesbeck.com: the API the car writes to, and the ABRP +forwarder. Data lives in InfluxDB 2; Grafana reads it from there for the dashboards +(`../k8s/grafana/`). + +``` +car ──live telemetry───▶ server ──▶ ABRP api.iternio.com/1/tlm/send + ──daily chunks─────▶ │ + ──charging sessions▶ ▼ + InfluxDB 2 ◀── Grafana +``` + +## Configuration + +Environment only; the server refuses to start when a required value is missing. + +| Name | Notes | +|---|---| +| `INFLUX_URL`, `INFLUX_TOKEN`, `INFLUX_ORG` | required; the token needs read/write on the bucket | +| `INFLUX_BUCKET` | default `mg4` | +| `INGEST_TOKEN` | required, at least 24 characters: `openssl rand -hex 32` | +| `ABRP_API_KEY`, `ABRP_USER_TOKEN` | optional; both needed to forward | +| `ABRP_MIN_INTERVAL_SECONDS` | default 5 | +| `LISTEN_ADDR` | default `:8080` | + +The ABRP credentials never reach the car. + +## Endpoints + +All but `/v1/health` need `Authorization: Bearer `. + +- `POST /v1/telemetry` — `{ installationId, samples: [...] }`, up to 500 samples in ABRP's `tlm` + field names and units (`utc` in epoch seconds; flags as 0/1 or booleans). Unknown fields are + dropped, known ones bounded. Stored, then the newest sample is forwarded to ABRP if it is under + 5 minutes old. +- `PUT /v1/consumption/daily` — one delta chunk of a day's consumption. +- `PUT /v1/charging-sessions/{sessionId}` — a finished DC session with its curve. +- `GET /v1/consumption/history/{installationId}` — the car reads its own totals back. +- `GET /v1/health` — `{"status":"ok"}`. + +A bad request is a 400 naming the field; a database failure is a 500 without detail. + +## Storage + +One bucket, `installationId` tag on every point: + +| Measurement | Timestamp | Tags | Fields | +|---|---|---|---| +| `telemetry` | sample `utc` | — | the ABRP fields that were sent | +| `consumption_chunk` | `recordedAt` | `date` | km, kwh, hours, socDrop, sohPercent | +| `charging_session` | `startedAt` | `sessionId` | endedAtMs, duration, energy, cost, start/end SOC | +| `charging_curve` | point time | `sessionId` | powerKw, socPercent | + +A point with the same measurement, tags and timestamp replaces the earlier one, so the car's +retries are idempotent: a cached chunk is re-sent with its original `recordedAt`. Days and the +lifetime total are summed from the chunks when read. Numbers are always written as floats, +because InfluxDB fixes a field's type on first write. + +## Development + +```sh +nix develop # from the repository root: Go, govulncheck, InfluxDB 2 +cd server +go test ./... +govulncheck ./... +``` + +## Container image + +```sh +podman build -t git.stinnesbeck.com/nils/mg4-server: . +podman push git.stinnesbeck.com/nils/mg4-server: +``` + +The build runs `go vet` and the tests. The image is a static binary on distroless (about +11 MB), running as UID 65532 on port 8080. diff --git a/server/abrp.go b/server/abrp.go new file mode 100644 index 0000000..9869cc6 --- /dev/null +++ b/server/abrp.go @@ -0,0 +1,139 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// Forwarder sends live telemetry on to A Better Route Planner. +// +// The ABRP credentials exist only here, from ABRP_API_KEY and ABRP_USER_TOKEN in the +// server's environment; the car never holds them. Forwarding is off when either is empty. +// +// Only the newest sample of a batch is sent, and at most one per MinInterval. A failed +// forward is logged and dropped, never queued: ABRP plans from the car's state now, and a +// backlog replayed later would describe a car that has moved on. The stored copy in +// InfluxDB is unaffected either way. +type Forwarder struct { + APIKey string + UserToken string + MinInterval time.Duration + Endpoint string + Client *http.Client + + mu sync.Mutex + lastUTC int64 + lastSentAt time.Time +} + +const ( + abrpEndpoint = "https://api.iternio.com/1/tlm/send" + // Older than this, a sample is history rather than live state, and ABRP gets nothing. + abrpMaxAge = 5 * time.Minute +) + +func (f *Forwarder) configured() bool { + return f != nil && f.APIKey != "" && f.UserToken != "" +} + +// pick chooses the sample to forward, or false when there is nothing new or it is too +// soon. It records the choice, so two batches arriving together cannot both forward. +func (f *Forwarder) pick(samples []TelemetrySample, now time.Time) (TelemetrySample, bool) { + if len(samples) == 0 { + return TelemetrySample{}, false + } + newest := samples[0] + for _, s := range samples[1:] { + if s.UTC > newest.UTC { + newest = s + } + } + if now.Sub(time.Unix(newest.UTC, 0)) > abrpMaxAge { + return TelemetrySample{}, false + } + f.mu.Lock() + defer f.mu.Unlock() + if f.lastUTC != 0 && newest.UTC <= f.lastUTC { + return TelemetrySample{}, false + } + if !f.lastSentAt.IsZero() && now.Sub(f.lastSentAt) < f.MinInterval { + return TelemetrySample{}, false + } + f.lastUTC = newest.UTC + f.lastSentAt = now + return newest, true +} + +// Forward is fire-and-forget: the car's request is answered without waiting for ABRP. +func (f *Forwarder) Forward(samples []TelemetrySample) { + if !f.configured() { + return + } + sample, ok := f.pick(samples, time.Now()) + if !ok { + return + } + go func() { + if err := f.send(context.Background(), sample); err != nil { + slog.Warn("ABRP forward failed", "error", err) + } + }() +} + +// tlm is the object ABRP expects: the same field names, flags as 0/1. +func tlm(sample TelemetrySample) map[string]any { + out := map[string]any{"utc": sample.UTC} + for field, value := range sample.Numbers { + out[field] = value + } + for field, value := range sample.Flags { + if value { + out[field] = 1 + } else { + out[field] = 0 + } + } + return out +} + +func (f *Forwarder) send(ctx context.Context, sample TelemetrySample) error { + payload, err := json.Marshal(tlm(sample)) + if err != nil { + return err + } + // Credentials go in the form body, never the URL, which ends up in access logs. + form := url.Values{"api_key": {f.APIKey}, "token": {f.UserToken}, "tlm": {string(payload)}} + endpoint := f.Endpoint + if endpoint == "" { + endpoint = abrpEndpoint + } + ctx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + client := f.Client + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + // A url.Error from the client quotes the URL, which holds no credentials. + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + // The response body can echo the request; only the status is reported. + return fmt.Errorf("rejected: HTTP %d", resp.StatusCode) + } + return nil +} diff --git a/server/api.go b/server/api.go new file mode 100644 index 0000000..89b8923 --- /dev/null +++ b/server/api.go @@ -0,0 +1,157 @@ +package main + +import ( + "crypto/subtle" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "strings" +) + +// API serves the car. Every endpoint but /v1/health needs the ingest token. +// +// POST /v1/telemetry live samples; stored, newest forwarded to ABRP +// PUT /v1/consumption/daily one delta chunk of a day's consumption +// PUT /v1/charging-sessions/{sessionId} one finished DC charging session +// GET /v1/consumption/history/{installationId} the car reads its own totals back +// GET /v1/health unauthenticated liveness probe +type API struct { + Store Store + IngestToken string + Forwarder *Forwarder +} + +const maxBodyBytes = 512 * 1024 + +func (a *API) Routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /v1/health", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + }) + mux.HandleFunc("POST /v1/telemetry", a.ingest(a.telemetry)) + mux.HandleFunc("PUT /v1/consumption/daily", a.ingest(a.consumption)) + mux.HandleFunc("PUT /v1/charging-sessions/{sessionId}", a.ingest(a.chargingSession)) + mux.HandleFunc("GET /v1/consumption/history/{installationId}", a.ingest(a.history)) + return mux +} + +// hasIngestToken compares in constant time. main refuses to start with a token under 24 +// characters, so an empty or short configured token can never match here. +func (a *API) hasIngestToken(r *http.Request) bool { + header := r.Header.Get("Authorization") + supplied, ok := strings.CutPrefix(header, "Bearer ") + if !ok || len(a.IngestToken) < minIngestTokenLength { + return false + } + return subtle.ConstantTimeCompare([]byte(supplied), []byte(a.IngestToken)) == 1 +} + +type handler func(w http.ResponseWriter, r *http.Request, body []byte) error + +// ingest wraps a handler with the token check, the body limit and error mapping: a +// validation failure is a 400 naming the field; anything else is a 500 whose detail +// stays in the server log. +func (a *API) ingest(h handler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !a.hasIngestToken(r) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes+1)) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body could not be read"}) + return + } + if len(body) > maxBodyBytes { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body is too large"}) + return + } + if err := h(w, r, body); err != nil { + var verr *ValidationError + if errors.As(err, &verr) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": verr.Error()}) + return + } + slog.Error("request failed", "method", r.Method, "path", r.URL.Path, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + } + } +} + +// telemetry stores the batch first; only once the write succeeded is the newest sample +// handed to ABRP, so the car's retry of a failed request cannot leave ABRP with data the +// database never got. +func (a *API) telemetry(w http.ResponseWriter, r *http.Request, body []byte) error { + batch, err := validateTelemetryBatch(body) + if err != nil { + return err + } + if err := a.Store.WriteTelemetry(r.Context(), batch.InstallationID, batch.Samples); err != nil { + return err + } + a.Forwarder.Forward(batch.Samples) + w.WriteHeader(http.StatusNoContent) + return nil +} + +// consumption stores one delta chunk at its recordedAt. Reads add a day's chunks up, so a +// chunk the car re-sends from its offline cache replaces itself instead of counting twice. +func (a *API) consumption(w http.ResponseWriter, r *http.Request, body []byte) error { + chunk, err := validateConsumption(body) + if err != nil { + return err + } + if err := a.Store.WriteConsumptionChunk(r.Context(), chunk); err != nil { + return err + } + w.WriteHeader(http.StatusNoContent) + return nil +} + +func (a *API) chargingSession(w http.ResponseWriter, r *http.Request, body []byte) error { + session, err := validateChargingSession(body, r.PathValue("sessionId")) + if err != nil { + return err + } + if err := a.Store.WriteChargingSession(r.Context(), session); err != nil { + return err + } + w.WriteHeader(http.StatusNoContent) + return nil +} + +// history is the car's read-back of its own uploaded totals, so an app reinstall does not +// blank its Consumption tab. Up to 400 days, newest first, plus the lifetime sum of all. +func (a *API) history(w http.ResponseWriter, r *http.Request, _ []byte) error { + id, err := validUUID(r.PathValue("installationId"), "installationId") + if err != nil { + return err + } + chunks, err := a.Store.ReadConsumptionChunks(r.Context(), id) + if err != nil { + return err + } + days := aggregateDays(chunks) + var lifetime Totals + if len(days) > 0 { + lifetime = days[0].Lifetime + } + if len(days) > 400 { + days = days[:400] + } + writeJSON(w, http.StatusOK, struct { + Records []DailyConsumption `json:"records"` + Lifetime Totals `json:"lifetime"` + }{Records: days, Lifetime: lifetime}) + return nil +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(value); err != nil { + slog.Warn("response not written", "error", err) + } +} diff --git a/server/api_test.go b/server/api_test.go new file mode 100644 index 0000000..c466fde --- /dev/null +++ b/server/api_test.go @@ -0,0 +1,170 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +type fakeStore struct { + mu sync.Mutex + telemetry []TelemetrySample + chunks []ConsumptionChunk + sessions []ChargingSession + fail error +} + +func (f *fakeStore) WriteTelemetry(_ context.Context, _ string, s []TelemetrySample) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.fail != nil { + return f.fail + } + f.telemetry = append(f.telemetry, s...) + return nil +} + +func (f *fakeStore) WriteConsumptionChunk(_ context.Context, c ConsumptionChunk) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.fail != nil { + return f.fail + } + f.chunks = append(f.chunks, c) + return nil +} + +func (f *fakeStore) WriteChargingSession(_ context.Context, s ChargingSession) error { + f.mu.Lock() + defer f.mu.Unlock() + f.sessions = append(f.sessions, s) + return f.fail +} + +func (f *fakeStore) ReadConsumptionChunks(context.Context, string) ([]ConsumptionChunk, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.chunks, f.fail +} + +const testToken = "ingest-test-token-0123456789abcdef" + +func newTestAPI() (*API, *fakeStore) { + store := &fakeStore{} + return &API{Store: store, IngestToken: testToken, Forwarder: &Forwarder{}}, store +} + +func do(t *testing.T, h http.Handler, method, path, token, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func TestHealthNeedsNoToken(t *testing.T) { + api, _ := newTestAPI() + rec := do(t, api.Routes(), "GET", "/v1/health", "", "") + if rec.Code != 200 || strings.TrimSpace(rec.Body.String()) != `{"status":"ok"}` { + t.Fatalf("%d %s", rec.Code, rec.Body) + } +} + +func TestEveryOtherEndpointNeedsTheIngestToken(t *testing.T) { + api, _ := newTestAPI() + h := api.Routes() + for _, c := range []struct{ method, path string }{ + {"POST", "/v1/telemetry"}, + {"PUT", "/v1/consumption/daily"}, + {"PUT", "/v1/charging-sessions/" + testSession}, + {"GET", "/v1/consumption/history/" + testInstallation}, + } { + for _, token := range []string{"", "wrong-token-0123456789abcdef", testToken + "x"} { + if rec := do(t, h, c.method, c.path, token, "{}"); rec.Code != 401 { + t.Errorf("%s %s with %q: want 401, got %d", c.method, c.path, token, rec.Code) + } + } + } +} + +func TestTelemetryIsStoredAndBadInputIsA400(t *testing.T) { + api, store := newTestAPI() + h := api.Routes() + rec := do(t, h, "POST", "/v1/telemetry", testToken, string(telemetryBody(`{"utc":1790000000,"soc":80}`))) + if rec.Code != 204 || len(store.telemetry) != 1 { + t.Fatalf("%d, stored %d", rec.Code, len(store.telemetry)) + } + rec = do(t, h, "POST", "/v1/telemetry", testToken, string(telemetryBody(`{"utc":1790000000,"soc":120}`))) + if rec.Code != 400 || !strings.Contains(rec.Body.String(), "samples[0].soc is invalid") { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + if rec := do(t, h, "GET", "/v1/telemetry", testToken, ""); rec.Code != 405 { + t.Fatalf("GET on a POST route: want 405, got %d", rec.Code) + } +} + +func TestAFailedWriteIsA500WithoutDetail(t *testing.T) { + api, store := newTestAPI() + store.fail = errors.New("influx says: token xyz rejected") + rec := do(t, api.Routes(), "POST", "/v1/telemetry", testToken, string(telemetryBody(`{"utc":1790000000,"soc":80}`))) + if rec.Code != 500 || strings.Contains(rec.Body.String(), "xyz") { + t.Fatalf("%d %s", rec.Code, rec.Body) + } +} + +func TestOversizedBodiesAreRejected(t *testing.T) { + api, _ := newTestAPI() + big := `{"x":"` + strings.Repeat("a", maxBodyBytes) + `"}` + if rec := do(t, api.Routes(), "PUT", "/v1/consumption/daily", testToken, big); rec.Code != 400 { + t.Fatalf("want 400, got %d", rec.Code) + } +} + +func TestHistoryReturnsDaysNewestFirstAndTheLifetime(t *testing.T) { + api, _ := newTestAPI() + h := api.Routes() + day := func(date string, at int64) string { + return `{"installationId":"` + testInstallation + `","date":"` + date + `","recordedAt":` + + jsonInt(at) + `,"day":{"km":2.5,"kwh":0.4,"hours":0.03,"socDrop":0.6}}` + } + // Two chunks on one day and one on the next. Retry dedupe is InfluxDB's overwrite and is + // covered by the end-to-end check, not this fake. + for _, body := range []string{day("2026-10-16", 1000), day("2026-10-16", 2000), day("2026-10-17", 3000)} { + if rec := do(t, h, "PUT", "/v1/consumption/daily", testToken, body); rec.Code != 204 { + t.Fatalf("chunk: %d %s", rec.Code, rec.Body) + } + } + rec := do(t, h, "GET", "/v1/consumption/history/"+testInstallation, testToken, "") + if rec.Code != 200 { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + var got struct { + Records []struct { + Date string `json:"date"` + Day Totals `json:"day"` + } `json:"records"` + Lifetime Totals `json:"lifetime"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if len(got.Records) != 2 || got.Records[0].Date != "2026-10-17" || got.Records[1].Day.Km != 5 || got.Lifetime.Km != 7.5 { + t.Fatalf("%s", rec.Body) + } + if rec := do(t, h, "GET", "/v1/consumption/history/not-a-uuid", testToken, ""); rec.Code != 400 { + t.Fatalf("bad id: want 400, got %d", rec.Code) + } +} + +func jsonInt(v int64) string { + b, _ := json.Marshal(v) + return string(b) +} diff --git a/server/days.go b/server/days.go new file mode 100644 index 0000000..2651bc6 --- /dev/null +++ b/server/days.go @@ -0,0 +1,51 @@ +package main + +import "sort" + +// aggregateDays folds chunks into days, newest day first, each with its running lifetime +// total. Pure, so the arithmetic is tested without a database. +func aggregateDays(chunks []ConsumptionChunk) []DailyConsumption { + type day struct { + DailyConsumption + sohAt int64 + hasSoh bool + } + byDate := map[string]*day{} + for _, chunk := range chunks { + d, ok := byDate[chunk.Date] + if !ok { + d = &day{DailyConsumption: DailyConsumption{ + InstallationID: chunk.InstallationID, + Date: chunk.Date, + RecordedAt: chunk.RecordedAt, + }} + byDate[chunk.Date] = d + } + d.Day.add(chunk.Day) + if chunk.RecordedAt.After(d.RecordedAt) { + d.RecordedAt = chunk.RecordedAt + } + // The newest manual SoH of the day wins. + at := chunk.RecordedAt.UnixMilli() + if chunk.SohPercent != nil && (!d.hasSoh || at > d.sohAt) { + soh := *chunk.SohPercent + d.SohPercent = &soh + d.sohAt = at + d.hasSoh = true + } + } + days := make([]DailyConsumption, 0, len(byDate)) + for _, d := range byDate { + days = append(days, d.DailyConsumption) + } + sort.Slice(days, func(i, j int) bool { return days[i].Date < days[j].Date }) + var running Totals + for i := range days { + running.add(days[i].Day) + days[i].Lifetime = running + } + for i, j := 0, len(days)-1; i < j; i, j = i+1, j-1 { + days[i], days[j] = days[j], days[i] + } + return days +} diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..ed82e11 --- /dev/null +++ b/server/go.mod @@ -0,0 +1,15 @@ +module git.stinnesbeck.com/nils/mg4/server + +go 1.26.0 + +toolchain go1.26.9 + +require github.com/influxdata/influxdb-client-go/v2 v2.14.0 + +require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/google/uuid v1.3.1 // indirect + github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect + github.com/oapi-codegen/runtime v1.0.0 // indirect + golang.org/x/net v0.60.0 // indirect +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..7303d63 --- /dev/null +++ b/server/go.sum @@ -0,0 +1,27 @@ +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/influxdata/influxdb-client-go/v2 v2.14.0 h1:AjbBfJuq+QoaXNcrova8smSjwJdUHnwvfjMF71M1iI4= +github.com/influxdata/influxdb-client-go/v2 v2.14.0/go.mod h1:Ahpm3QXKMJslpXl3IftVLVezreAUtBOTZssDrjZEFHI= +github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7wlPfJLvMCdtV4zPulc4uCPrlywQOmbFOhgQNU= +github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/oapi-codegen/runtime v1.0.0 h1:P4rqFX5fMFWqRzY9M/3YF9+aPSPPB06IzP2P7oOxrWo= +github.com/oapi-codegen/runtime v1.0.0/go.mod h1:LmCUMQuPB4M/nLXilQXhHw+BLZdDb18B34OO356yJ/A= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/server/logic_test.go b/server/logic_test.go new file mode 100644 index 0000000..bf83bf9 --- /dev/null +++ b/server/logic_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "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 TestTlmSendsFlagsAsZeroOrOne(t *testing.T) { + got := tlm(TelemetrySample{UTC: testNowS, Numbers: map[string]float64{"soc": 50}, + Flags: map[string]bool{"is_charging": true, "is_parked": false}}) + if got["utc"] != int64(testNowS) || got["soc"] != 50.0 || got["is_charging"] != 1 || got["is_parked"] != 0 { + t.Fatalf("tlm %+v", got) + } +} + +func TestForwarderPicksOnlyTheNewestFreshSampleOncePerInterval(t *testing.T) { + f := &Forwarder{MinInterval: 5 * time.Second} + now := time.Unix(testNowS, 0) + got, ok := f.pick([]TelemetrySample{{UTC: testNowS - 10}, {UTC: testNowS - 2}, {UTC: testNowS - 5}}, now) + if !ok || got.UTC != testNowS-2 { + t.Fatalf("picked %+v %v", got, ok) + } + if _, ok := f.pick([]TelemetrySample{{UTC: testNowS}}, now.Add(time.Second)); ok { + t.Fatal("forwarded again within the interval") + } + if _, ok := f.pick([]TelemetrySample{{UTC: testNowS - 3}}, now.Add(time.Minute)); ok { + t.Fatal("forwarded an older sample than the last one") + } + // A sample from ten minutes ago describes a car that has moved on. + fresh := &Forwarder{MinInterval: 5 * time.Second} + if _, ok := fresh.pick([]TelemetrySample{{UTC: testNowS - 600}}, now); ok { + t.Fatal("forwarded a stale sample") + } +} + +func TestForwarderPutsCredentialsInTheBodyNotTheURL(t *testing.T) { + received := make(chan *http.Request, 1) + var form url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + form, _ = url.ParseQuery(string(body)) + received <- r + })) + defer srv.Close() + f := &Forwarder{APIKey: "key-123", UserToken: "token-456", Endpoint: srv.URL + "/1/tlm/send"} + if err := f.send(t.Context(), TelemetrySample{UTC: testNowS, Numbers: map[string]float64{"soc": 80}}); err != nil { + t.Fatal(err) + } + r := <-received + if r.URL.RawQuery != "" { + t.Fatalf("credentials leaked into the URL: %s", r.URL.RawQuery) + } + if form.Get("api_key") != "key-123" || form.Get("token") != "token-456" || form.Get("tlm") != `{"soc":80,"utc":1790000000}` { + t.Fatalf("form %v", form) + } +} + +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" || c.abrpMinInterval != 5*time.Second { + 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..18ca0b6 --- /dev/null +++ b/server/main.go @@ -0,0 +1,113 @@ +// Command server is the Nils' MG4 car server: the API the car writes its telemetry, +// consumption and charging sessions to, stored in InfluxDB 2 and forwarded live to ABRP. +// Dashboards are Grafana's job; Grafana reads the same bucket directly. +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "strconv" + "syscall" + "time" +) + +const minIngestTokenLength = 24 + +type config struct { + listenAddr string + influxURL string + influxToken string + influxOrg string + influxBucket string + ingestToken string + abrpAPIKey string + abrpUserToken string + abrpMinInterval time.Duration +} + +// 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"), + abrpAPIKey: getenv("ABRP_API_KEY"), + abrpUserToken: getenv("ABRP_USER_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) + } + c.abrpMinInterval = 5 * time.Second + if raw := getenv("ABRP_MIN_INTERVAL_SECONDS"); raw != "" { + seconds, err := strconv.ParseFloat(raw, 64) + if err != nil || seconds < 0 { + return config{}, errors.New("ABRP_MIN_INTERVAL_SECONDS must be a non-negative number") + } + c.abrpMinInterval = time.Duration(seconds * float64(time.Second)) + } + 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() + forwarder := &Forwarder{ + APIKey: cfg.abrpAPIKey, + UserToken: cfg.abrpUserToken, + MinInterval: cfg.abrpMinInterval, + Client: &http.Client{Timeout: 10 * time.Second}, + } + api := &API{Store: store, IngestToken: cfg.ingestToken, Forwarder: forwarder} + + 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, "abrp", forwarder.configured()) + 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..955f932 --- /dev/null +++ b/server/model.go @@ -0,0 +1,108 @@ +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 +} diff --git a/server/store.go b/server/store.go new file mode 100644 index 0000000..9f49601 --- /dev/null +++ b/server/store.go @@ -0,0 +1,162 @@ +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) +} + +// 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 +// +// 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 +} diff --git a/server/validate.go b/server/validate.go new file mode 100644 index 0000000..146a64e --- /dev/null +++ b/server/validate.go @@ -0,0 +1,327 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "regexp" + "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; every known field is bounded; a flag may be 0/1 or a boolean. +func validateTelemetryBatch(body []byte) (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") + } + samples := make([]TelemetrySample, 0, len(raw)) + for i, entry := range raw { + sample, err := validateSample(entry, fmt.Sprintf("samples[%d]", i)) + if err != nil { + return TelemetryBatch{}, err + } + samples = append(samples, sample) + } + return TelemetryBatch{InstallationID: id, Samples: samples}, nil +} + +func validateSample(entry any, name string) (TelemetrySample, error) { + o, err := asObject(entry, name) + if err != nil { + return TelemetrySample{}, err + } + utc, err := integer(o["utc"], name+".utc") + if err != nil || utc < utcMin || utc > utcMax { + return TelemetrySample{}, invalid("%s.utc is invalid", name) + } + 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 { + return TelemetrySample{}, err + } + 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: + return TelemetrySample{}, invalid("%s.%s is invalid", name, field) + } + default: + return TelemetrySample{}, invalid("%s.%s is invalid", name, field) + } + } + // A position is only a position as a pair. + _, hasLat := sample.Numbers["lat"] + _, hasLon := sample.Numbers["lon"] + if hasLat != hasLon { + return TelemetrySample{}, invalid("%s.lat and lon must be sent together", name) + } + return sample, nil +} diff --git a/server/validate_test.go b/server/validate_test.go new file mode 100644 index 0000000..baead87 --- /dev/null +++ b/server/validate_test.go @@ -0,0 +1,185 @@ +package main + +import ( + "errors" + "strings" + "testing" +) + +const ( + testInstallation = "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b" + testSession = "11111111-2222-4333-8444-555555555555" + testNowS = 1_790_000_000 +) + +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}`)) + 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}`)) + 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 TestTelemetryRejectsBadSamples(t *testing.T) { + for name, sample := range map[string]string{ + "milliseconds": `{"utc":1790000000000}`, + "fractional utc": `{"utc":1790000000.5}`, + "soc above 100": `{"utc":1790000000,"soc":101}`, + "string speed": `{"utc":1790000000,"speed":"fast"}`, + "flag 2": `{"utc":1790000000,"is_parked":2}`, + "half position": `{"utc":1790000000,"lat":52.1}`, + "not an object": `5`, + } { + if _, err := validateTelemetryBatch(telemetryBody(sample)); !isValidationError(err) { + t.Errorf("%s: want ValidationError, got %v", name, err) + } + } +} + +func TestTelemetryRejectsEmptyAndOversizedBatches(t *testing.T) { + if _, err := validateTelemetryBatch(telemetryBody("")); !isValidationError(err) { + t.Error("empty batch accepted") + } + many := strings.TrimSuffix(strings.Repeat(`{"utc":1790000000},`, 501), ",") + if _, err := validateTelemetryBatch(telemetryBody(many)); !isValidationError(err) { + t.Error("501 samples accepted") + } + if _, err := validateTelemetryBatch([]byte(`{"installationId":"nope","samples":[{"utc":1790000000}]}`)); !isValidationError(err) { + t.Error("bad installation id accepted") + } +} diff --git a/web/.dockerignore b/web/.dockerignore deleted file mode 100644 index 6614541..0000000 --- a/web/.dockerignore +++ /dev/null @@ -1,7 +0,0 @@ -node_modules -.next -.env* -!.env.example -*.tsbuildinfo -Dockerfile -.dockerignore diff --git a/web/.env.example b/web/.env.example deleted file mode 100644 index 1a81b62..0000000 --- a/web/.env.example +++ /dev/null @@ -1,18 +0,0 @@ -# InfluxDB 2 — a token with read/write on the bucket. -INFLUX_URL=http://influxdb:8086 -INFLUX_TOKEN=replace-with-an-influxdb-token -INFLUX_ORG=mg4 -INFLUX_BUCKET=mg4 - -# The car's write token (at least 24 characters): openssl rand -hex 32 -INGEST_TOKEN=replace-with-a-long-random-write-token -# Signs dashboard sessions; rotating it signs every browser out: openssl rand -hex 32 -SESSION_SECRET=replace-with-a-long-random-session-secret -# Dashboard login, at least 8 characters. -DASHBOARD_PASSWORD=replace-with-a-strong-dashboard-password - -# ABRP forwarding; off when either is empty. -ABRP_API_KEY= -ABRP_USER_TOKEN= -# Minimum seconds between two samples forwarded to ABRP. -ABRP_MIN_INTERVAL_SECONDS=5 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/Dockerfile b/web/Dockerfile deleted file mode 100644 index 22663d7..0000000 --- a/web/Dockerfile +++ /dev/null @@ -1,27 +0,0 @@ -# Self-hosted image of the car server: Next.js standalone output on Node 22. -# Configuration comes entirely from the environment at runtime (see .env.example); -# nothing secret is baked in. - -FROM node:22-alpine AS deps -WORKDIR /app -COPY package.json package-lock.json ./ -RUN npm ci --no-audit --no-fund - -FROM node:22-alpine AS build -WORKDIR /app -ENV NEXT_TELEMETRY_DISABLED=1 -COPY --from=deps /app/node_modules ./node_modules -COPY . . -RUN npm test && npm run build - -FROM node:22-alpine -WORKDIR /app -ENV NODE_ENV=production \ - NEXT_TELEMETRY_DISABLED=1 \ - PORT=3000 \ - HOSTNAME=0.0.0.0 -COPY --from=build --chown=10001:10001 /app/.next/standalone ./ -COPY --from=build --chown=10001:10001 /app/.next/static ./.next/static -USER 10001:10001 -EXPOSE 3000 -CMD ["node", "server.js"] diff --git a/web/README.md b/web/README.md deleted file mode 100644 index 71ce266..0000000 --- a/web/README.md +++ /dev/null @@ -1,88 +0,0 @@ -# Nils' MG4 — car server - -Next.js app, self-hosted at car.stinnesbeck.com: the ingest API the car writes to, the ABRP -forwarder, and a phone-shaped dashboard for reading the history back. Data lives in -InfluxDB 2. Based on upstream's Vercel/MongoDB app in fatihdonmezdev/FatihsMG4. - -``` -car ──live telemetry───▶ server ──▶ ABRP api.iternio.com/1/tlm/send - ──daily chunks─────▶ │ - ──charging sessions▶ ▼ - InfluxDB 2 -``` - -## Credentials - -| | Who holds it | What it opens | -|---|---|---| -| `INGEST_TOKEN` | the car, entered in the app | the write endpoints, and the car's own history read | -| `DASHBOARD_PASSWORD` | you, in a browser | the dashboard and the `GET /v1/history` reads | -| `ABRP_API_KEY`, `ABRP_USER_TOKEN` | only the server | forwarding live telemetry to ABRP | - -The ABRP credentials never reach the car. A missing or empty one switches forwarding off. - -## Environment variables - -See `.env.example`. In Kubernetes they come from the Secret in `../k8s/`. - -| Name | Notes | -|---|---| -| `INFLUX_URL`, `INFLUX_TOKEN`, `INFLUX_ORG`, `INFLUX_BUCKET` | bucket defaults to `mg4`; the token needs read/write on it | -| `INGEST_TOKEN` | at least 24 characters, or every write is refused: `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 | -| `ABRP_API_KEY`, `ABRP_USER_TOKEN` | optional; both needed to forward | -| `ABRP_MIN_INTERVAL_SECONDS` | default 5 | - -## Endpoints - -Write, `Authorization: Bearer `: - -- `POST /v1/telemetry` — `{ installationId, samples: [...] }`, up to 500 samples in ABRP's `tlm` - field names and units (`utc` in epoch seconds; flags as 0/1 or booleans). Stored, then the - newest sample is forwarded to ABRP if it is under 5 minutes old. -- `PUT /v1/consumption/daily` — one delta chunk of a day's consumption. -- `PUT /v1/charging-sessions/:sessionId` - -Read: - -- `GET /v1/consumption/history/:installationId` — ingest token; the car reads its own totals back. -- `GET /v1/history/:installationId/consumption`, `.../charging` — session cookie or - `Authorization: Bearer `. - -`GET /v1/health` is unauthenticated and returns `{"status":"ok"}`. - -## Storage - -One bucket, `installationId` tag on every point: - -| Measurement | Timestamp | Tags | Fields | -|---|---|---|---| -| `telemetry` | sample `utc` | — | the ABRP fields that were sent | -| `consumption_chunk` | `recordedAt` | `date` | km, kwh, hours, socDrop, sohPercent | -| `charging_session` | `startedAt` | `sessionId` | endedAtMs, duration, energy, cost, start/end SOC | -| `charging_curve` | point time | `sessionId` | powerKw, socPercent | - -A point with the same measurement, tags and timestamp replaces the earlier one, so the car's -retries are idempotent: a cached chunk is re-sent with its original `recordedAt`. Days and the -lifetime total are summed from the chunks when read. All numbers are written as floats, because -InfluxDB fixes a field's type on first write. - -## Local development - -```sh -nix develop # from the repository root: Node 22 and InfluxDB 2 -cp .env.example .env.local # Next loads .env.local automatically -npm install -npm run dev -npm test # validation, aggregation and forwarding rules; no database -``` - -## Container image - -```sh -podman build -t git.stinnesbeck.com/nils/mg4-server: . -podman push git.stinnesbeck.com/nils/mg4-server: -``` - -The build runs the unit tests. The image runs as UID 10001 and listens on port 3000. diff --git a/web/next.config.mjs b/web/next.config.mjs deleted file mode 100644 index 7bd72cb..0000000 --- a/web/next.config.mjs +++ /dev/null @@ -1,8 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - // A self-contained server in .next/standalone, for the container image. - output: "standalone", - // 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 88db69d..0000000 --- a/web/package-lock.json +++ /dev/null @@ -1,1002 +0,0 @@ -{ - "name": "nilsmg4-web", - "version": "1.1.2", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "nilsmg4-web", - "version": "1.1.2", - "dependencies": { - "@influxdata/influxdb-client": "^1.35.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/@influxdata/influxdb-client": { - "version": "1.35.0", - "resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.35.0.tgz", - "integrity": "sha512-woWMi8PDpPQpvTsRaUw4Ig+nOGS/CWwAwS66Fa1Vr/EkW+NEwxI8YfPBsdBMn33jK2Y86/qMiiuX/ROHIkJLTw==", - "license": "MIT" - }, - "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/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/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/react": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", - "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", - "license": "MIT", - "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", - "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/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/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" - } - } -} diff --git a/web/package.json b/web/package.json deleted file mode 100644 index c1a59a3..0000000 --- a/web/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "nilsmg4-web", - "version": "1.1.2", - "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": { - "@influxdata/influxdb-client": "^1.35.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" -} 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 6cf9e42..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 ( - -
-

- No charging sessions yet. Only DC charges above 10 kW are metered; AC charging is not recorded. -

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

Sessions

-
    - {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 d87b07a..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 ( - -
-

No daily consumption records yet. The car uploads them as it drives.

-
-
- ); - } - - 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 && ( - Clear - )} -
-
-
- - {/* 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} - /> -
-
- - - -
- - ) : ( -

No consumption recorded for this date.

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

Daily energy

- -
- -
-

Efficiency trend

- -
- -
-

Lifetime

-
- - - - -
-
- -
-

Day by day

-
    - {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 d4242ec..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: "Nils' MG4", - description: "MG4 consumption and charging history", -}; - -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 88cabd7..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 ( -
-
-

Nils' MG4

-

Consumption and charging history

- {/* - 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 &&

Wrong password.

} -
-
- ); -} 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 6b1dc26..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 ( - -
-

- No data yet. It appears here once the car sends its first daily record. -

-
-
- ); - } - - 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 ? `Most recent of ${installationCount} cars` : "MG4"} - > -
- - 0 ? `${money(charge.cost / charge.gridEnergyKwh)}/kWh avg.` : undefined} - tone="gold" - /> - - -
- -
-

Last 30 days

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

Last charge

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

Battery and totals

-
- - - 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 55ee73a..0000000 --- a/web/src/app/v1/charging-sessions/[sessionId]/route.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { NextResponse } from "next/server"; -import { handle, parseJson, requireIngest } from "@/lib/api"; -import { writeChargingSession } from "@/lib/store"; -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; - await writeChargingSession(validateChargingSession(await parseJson(request), sessionId)); - 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 691fab5..0000000 --- a/web/src/app/v1/consumption/daily/route.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { NextResponse } from "next/server"; -import { handle, parseJson, requireIngest } from "@/lib/api"; -import { writeConsumptionChunk } from "@/lib/store"; -import { validateConsumption } from "@/lib/validate"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * One delta chunk (not a cumulative total) of a day's consumption. Stored as its own point - * at its recordedAt; reads add a day's chunks up. A chunk the car re-sends from its offline - * cache carries the same recordedAt and replaces itself, so a retry is never counted twice. - */ -export async function PUT(request: Request) { - const denied = requireIngest(request); - if (denied) return denied; - return handle(async () => { - await writeConsumptionChunk(validateConsumption(await parseJson(request))); - 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 ffd2bc3..0000000 --- a/web/src/app/v1/consumption/history/[installationId]/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { NextResponse } from "next/server"; -import { handle, requireIngest } from "@/lib/api"; -import { readDays } from "@/lib/store"; -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 days = await readDays(validateUuid(installationId, "installationId")); - // Lifetime = sum of every day's totals, which is the newest day's running total. - const lifetime = days[0]?.lifetime ?? { km: 0, kwh: 0, hours: 0, socDrop: 0 }; - const records = days.slice(0, 400).map(({ lifetime: _lifetime, ...day }) => day); - return NextResponse.json({ records, lifetime }); - }); -} 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 434bf4d..0000000 --- a/web/src/app/v1/history/[installationId]/charging/route.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { NextResponse } from "next/server"; -import { handle, requireReader } from "@/lib/api"; -import { readChargingSessions } from "@/lib/store"; -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 records = await readChargingSessions(validateUuid(installationId, "installationId"), 200); - 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 158b0a8..0000000 --- a/web/src/app/v1/history/[installationId]/consumption/route.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { NextResponse } from "next/server"; -import { handle, requireReader } from "@/lib/api"; -import { readDays } from "@/lib/store"; -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 records = (await readDays(validateUuid(installationId, "installationId"))).slice(0, 400); - return NextResponse.json({ records }); - }); -} diff --git a/web/src/app/v1/telemetry/route.ts b/web/src/app/v1/telemetry/route.ts deleted file mode 100644 index ffbf8fb..0000000 --- a/web/src/app/v1/telemetry/route.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { NextResponse } from "next/server"; -import { forwardToAbrp } from "@/lib/abrp"; -import { handle, parseJson, requireIngest } from "@/lib/api"; -import { writeTelemetry } from "@/lib/store"; -import { validateTelemetryBatch } from "@/lib/validate"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * A batch of live samples from the car. Stored first; only once the write succeeded is the - * newest sample handed to ABRP, so the car's retry of a failed request cannot leave ABRP - * with data the database never got. - */ -export async function POST(request: Request) { - const denied = requireIngest(request); - if (denied) return denied; - return handle(async () => { - const batch = validateTelemetryBatch(await parseJson(request)); - await writeTelemetry(batch.installationId, batch.samples); - forwardToAbrp(batch.samples); - return new NextResponse(null, { status: 204 }); - }); -} diff --git a/web/src/components/charts.tsx b/web/src/components/charts.tsx deleted file mode 100644 index 351f5f6..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

No daily records yet

; - 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)} - - -
- - - Daily energy (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

Not enough days for a chart

; - 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 - - - - Average {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

No curve recorded for this session

; - 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 min - - - {Math.round(span / 60000)} min - - -
- - - Power (kW) - - - - State of charge (%) - -
- - ); -} - -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 242adc8..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: "Overview", icon: "◈" }, - { href: "/consumption", label: "Consumption", icon: "▤" }, - { href: "/charging", label: "Charging", 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/abrp.ts b/web/src/lib/abrp.ts deleted file mode 100644 index 87a7756..0000000 --- a/web/src/lib/abrp.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { TELEMETRY_FLAGS, type TelemetrySample } from "./types.ts"; - -/** - * Forwards live telemetry to A Better Route Planner. - * - * The ABRP credentials exist only here, as ABRP_API_KEY and ABRP_USER_TOKEN in the server's - * environment; the car never holds them. Forwarding is off when either is unset. - * - * Only the newest sample of a batch is sent, and at most one every ABRP_MIN_INTERVAL_SECONDS - * (default 5). A failed forward is logged and dropped, never queued: ABRP plans from the - * car's state now, and a backlog replayed later would describe a car that has moved on. - * The stored copy in InfluxDB is unaffected either way. - */ - -const API_URL = "https://api.iternio.com/1/tlm/send"; -const TIMEOUT_MS = 8_000; -/** Older than this, a sample is history rather than live state, and ABRP gets nothing. */ -const MAX_AGE_SECONDS = 300; - -const state = globalThis as unknown as { abrpLastUtc?: number; abrpLastSentMs?: number }; - -export function abrpConfigured(): boolean { - return Boolean(process.env.ABRP_API_KEY && process.env.ABRP_USER_TOKEN); -} - -function minIntervalMs(): number { - const seconds = Number(process.env.ABRP_MIN_INTERVAL_SECONDS ?? "5"); - return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : 5000; -} - -/** The tlm object ABRP expects: the same field names, flags as 0/1. */ -export function toTlm(sample: TelemetrySample): Record { - const tlm: Record = {}; - for (const [field, value] of Object.entries(sample)) { - if (typeof value === "number") tlm[field] = value; - } - for (const field of TELEMETRY_FLAGS) { - const value = sample[field]; - if (value !== undefined) tlm[field] = value ? 1 : 0; - } - return tlm; -} - -/** Picks the sample to forward, or null when there is nothing new or it is too soon. */ -export function pickForForward(samples: TelemetrySample[], nowMs: number): TelemetrySample | null { - if (samples.length === 0) return null; - const newest = samples.reduce((a, b) => (b.utc > a.utc ? b : a)); - if (nowMs / 1000 - newest.utc > MAX_AGE_SECONDS) return null; - if (state.abrpLastUtc !== undefined && newest.utc <= state.abrpLastUtc) return null; - if (state.abrpLastSentMs !== undefined && nowMs - state.abrpLastSentMs < minIntervalMs()) return null; - return newest; -} - -/** Fire-and-forget: the car's request is answered without waiting for ABRP. */ -export function forwardToAbrp(samples: TelemetrySample[]): void { - if (!abrpConfigured()) return; - const nowMs = Date.now(); - const sample = pickForForward(samples, nowMs); - if (!sample) return; - state.abrpLastUtc = sample.utc; - state.abrpLastSentMs = nowMs; - void send(sample).catch((error) => console.warn("ABRP forward failed:", error instanceof Error ? error.message : error)); -} - -async function send(sample: TelemetrySample): Promise { - // Credentials go in the form body, never the URL, which ends up in access logs. - const body = new URLSearchParams({ - api_key: process.env.ABRP_API_KEY ?? "", - token: process.env.ABRP_USER_TOKEN ?? "", - tlm: JSON.stringify(toTlm(sample)), - }); - const response = await fetch(API_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body, - signal: AbortSignal.timeout(TIMEOUT_MS), - }); - if (!response.ok) { - // The response body can echo the request; only the status is logged. - console.warn(`ABRP forward rejected: HTTP ${response.status}`); - } -} - -/** For tests: forget what was forwarded. */ -export function resetAbrpState(): void { - state.abrpLastUtc = undefined; - state.abrpLastSentMs = undefined; -} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts deleted file mode 100644 index a266559..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 InfluxDB, 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 50c1d07..0000000 --- a/web/src/lib/data.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { installationIds, readChargingSessions, readDays, readLatestTelemetry } from "./store"; -import type { ChargingSession, DailyConsumption, TelemetrySample } from "./types"; - -/** - * Reads for the dashboard. - * - * Every query is scoped to one installation id. There is one car today, but the id is on - * every point, 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[]; - latest: (TelemetrySample & { at: Date }) | null; -} - -/** 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 ids = await installationIds(); - return { id: ids[0] ?? null, 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: [], latest: null }; - const [days, sessions, latest] = await Promise.all([ - readDays(id), - readChargingSessions(id, sessionLimit), - readLatestTelemetry(id), - ]); - // Charts read left-to-right in time; the reads come newest-first so the limit keeps - // the recent end rather than the beginning of history. - return { - installationId: id, - installationCount: count, - days: days.slice(0, dayLimit).reverse(), - sessions, - latest, - }; -} - -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/days.ts b/web/src/lib/days.ts deleted file mode 100644 index e5a36fd..0000000 --- a/web/src/lib/days.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { ConsumptionChunk, DailyConsumption, Totals } from "./types.ts"; - -/** - * Folds chunks into days, newest day first, each with its running lifetime total. - * Pure, so the arithmetic is tested without a database. - */ -export function aggregateDays(chunks: ConsumptionChunk[]): DailyConsumption[] { - const byDate = new Map(); - for (const chunk of chunks) { - let day = byDate.get(chunk.date); - if (!day) { - day = { - installationId: chunk.installationId, - date: chunk.date, - recordedAt: chunk.recordedAt, - day: { km: 0, kwh: 0, hours: 0, socDrop: 0 }, - sohAt: -Infinity, - }; - byDate.set(chunk.date, day); - } - day.day.km += chunk.day.km; - day.day.kwh += chunk.day.kwh; - day.day.hours += chunk.day.hours; - day.day.socDrop += chunk.day.socDrop; - const at = chunk.recordedAt.getTime(); - if (at > day.recordedAt.getTime()) day.recordedAt = chunk.recordedAt; - // The newest manual SoH of the day wins. - if (chunk.sohPercent !== undefined && at > day.sohAt) { - day.sohPercent = chunk.sohPercent; - day.sohAt = at; - } - } - const ascending = [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date)); - const running: Totals = { km: 0, kwh: 0, hours: 0, socDrop: 0 }; - const days = ascending.map(({ sohAt: _sohAt, ...day }) => { - running.km += day.day.km; - running.kwh += day.day.kwh; - running.hours += day.day.hours; - running.socDrop += day.day.socDrop; - return { ...day, lifetime: { ...running } }; - }); - return days.reverse(); -} diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts deleted file mode 100644 index 4343772..0000000 --- a/web/src/lib/format.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** English formatting (en-GB: 24-hour clock, day-month dates) with euro for every number and date. */ - -const NUMBER = new Intl.NumberFormat("en-GB", { maximumFractionDigits: 1 }); -const PRECISE = new Intl.NumberFormat("en-GB", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); -const CURRENCY = new Intl.NumberFormat("en-GB", { style: "currency", currency: "EUR", maximumFractionDigits: 2 }); -const DATE = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "long", year: "numeric" }); -const DATE_TIME = new Intl.DateTimeFormat("en-GB", { - 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 h 05 min` — 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} min`; - return `${hours} h ${String(minutes).padStart(2, "0")} min`; -} - -/** 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/influx.ts b/web/src/lib/influx.ts deleted file mode 100644 index 4bb8c7d..0000000 --- a/web/src/lib/influx.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { InfluxDB, type QueryApi, type WriteApi } from "@influxdata/influxdb-client"; - -/** - * One InfluxDB client per server process. - * - * Configured entirely from the environment so the image carries no credentials: - * INFLUX_URL, INFLUX_TOKEN, INFLUX_ORG and INFLUX_BUCKET (default "mg4"). The global - * survives module reloads in dev, where Next re-evaluates modules on every edit. - */ -const globalForInflux = globalThis as unknown as { influx?: InfluxDB }; - -function env(name: string): string { - const value = process.env[name]; - if (!value) throw new Error(`${name} is not configured`); - return value; -} - -function client(): InfluxDB { - if (!globalForInflux.influx) { - globalForInflux.influx = new InfluxDB({ url: env("INFLUX_URL"), token: env("INFLUX_TOKEN"), timeout: 10_000 }); - } - return globalForInflux.influx; -} - -export function bucket(): string { - return process.env.INFLUX_BUCKET || "mg4"; -} - -/** A write API with millisecond precision; the caller must close() it to flush. */ -export function writeApi(): WriteApi { - return client().getWriteApi(env("INFLUX_ORG"), bucket(), "ms", { - // The caller waits for close(); a failed write must reach it rather than be retried - // in the background after the car has already been told it succeeded. - maxRetries: 0, - }); -} - -export function queryApi(): QueryApi { - return client().getQueryApi(env("INFLUX_ORG")); -} diff --git a/web/src/lib/session.ts b/web/src/lib/session.ts deleted file mode 100644 index 07fbf8a..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 = "nilsmg4_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/store.ts b/web/src/lib/store.ts deleted file mode 100644 index 7836506..0000000 --- a/web/src/lib/store.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { Point, flux } from "@influxdata/influxdb-client"; -import { aggregateDays } from "./days.ts"; -import { bucket, queryApi, writeApi } from "./influx.ts"; -import { - TELEMETRY_FLAGS, - TELEMETRY_NUMBERS, - type ChargingSession, - type ConsumptionChunk, - type CurvePoint, - type DailyConsumption, - type TelemetrySample, -} from "./types.ts"; - -/** - * Storage on InfluxDB 2. One bucket; every point carries an `installationId` tag. - * - * 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 - * telemetry time = sample utc ABRP tlm fields - * - * 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 numeric field is written as a float. InfluxDB fixes a field's type on first - * write, so a 0 sent as an integer would make every later 0.5 a rejected write. - */ - -const TAG = "installationId"; - -async function write(points: Point[]): Promise { - const api = writeApi(); - api.writePoints(points); - await api.close(); -} - -export async function writeConsumptionChunk(chunk: ConsumptionChunk): Promise { - const point = new Point("consumption_chunk") - .tag(TAG, chunk.installationId) - .tag("date", chunk.date) - .floatField("km", chunk.day.km) - .floatField("kwh", chunk.day.kwh) - .floatField("hours", chunk.day.hours) - .floatField("socDrop", chunk.day.socDrop) - .timestamp(chunk.recordedAt); - if (chunk.sohPercent !== undefined) point.floatField("sohPercent", chunk.sohPercent); - await write([point]); -} - -export async function writeChargingSession(session: ChargingSession): Promise { - const summary = new Point("charging_session") - .tag(TAG, session.installationId) - .tag("sessionId", session.sessionId) - .intField("endedAtMs", session.endedAt.getTime()) - .floatField("durationSeconds", session.durationSeconds) - .floatField("energyKwh", session.energyKwh) - .floatField("chargingLossPercent", session.chargingLossPercent) - .floatField("gridEnergyKwh", session.gridEnergyKwh) - .floatField("pricePerKwh", session.pricePerKwh) - .floatField("totalCost", session.totalCost) - .booleanField("isDemo", session.isDemo === true) - .timestamp(session.startedAt); - if (session.startSocPercent !== undefined) summary.floatField("startSocPercent", session.startSocPercent); - if (session.endSocPercent !== undefined) summary.floatField("endSocPercent", session.endSocPercent); - const curve = session.curve.map((p) => { - const point = new Point("charging_curve") - .tag(TAG, session.installationId) - .tag("sessionId", session.sessionId) - .floatField("powerKw", p.powerKw) - .timestamp(p.timestamp); - if (p.socPercent !== undefined) point.floatField("socPercent", p.socPercent); - return point; - }); - await write([summary, ...curve]); -} - -export async function writeTelemetry(installationId: string, samples: TelemetrySample[]): Promise { - const points = samples.map((sample) => { - const point = new Point("telemetry").tag(TAG, installationId).timestamp(new Date(sample.utc * 1000)); - for (const field of Object.keys(TELEMETRY_NUMBERS) as (keyof typeof TELEMETRY_NUMBERS)[]) { - const value = sample[field]; - if (value !== undefined) point.floatField(field, value); - } - for (const field of TELEMETRY_FLAGS) { - const value = sample[field]; - if (value !== undefined) point.booleanField(field, value); - } - return point; - }); - await write(points); -} - -// ---------- Reads ---------- - -type Row = Record & { _time: string }; - -async function rows(query: ReturnType): Promise { - return queryApi().collectRows(query); -} - -function num(row: Row, field: string): number | undefined { - const value = row[field]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -export async function readConsumptionChunks(installationId: string): Promise { - const result = await rows(flux` - from(bucket: ${bucket()}) - |> range(start: 0) - |> filter(fn: (r) => r._measurement == "consumption_chunk" and r.installationId == ${installationId}) - |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")`); - return result.map((row) => { - const sohPercent = num(row, "sohPercent"); - return { - installationId, - date: String(row.date), - recordedAt: new Date(row._time), - day: { - km: num(row, "km") ?? 0, - kwh: num(row, "kwh") ?? 0, - hours: num(row, "hours") ?? 0, - socDrop: num(row, "socDrop") ?? 0, - }, - ...(sohPercent === undefined ? {} : { sohPercent }), - }; - }); -} - -export async function readDays(installationId: string): Promise { - return aggregateDays(await readConsumptionChunks(installationId)); -} - -export async function readChargingSessions(installationId: string, limit: number): Promise { - const summaries = await rows(flux` - from(bucket: ${bucket()}) - |> range(start: 0) - |> filter(fn: (r) => r._measurement == "charging_session" and r.installationId == ${installationId}) - |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value") - |> group() - |> sort(columns: ["_time"], desc: true) - |> limit(n: ${limit})`); - if (summaries.length === 0) return []; - - const sessions: ChargingSession[] = summaries.map((row) => { - const startSocPercent = num(row, "startSocPercent"); - const endSocPercent = num(row, "endSocPercent"); - return { - installationId, - sessionId: String(row.sessionId), - startedAt: new Date(row._time), - endedAt: new Date(num(row, "endedAtMs") ?? Date.parse(row._time)), - durationSeconds: num(row, "durationSeconds") ?? 0, - energyKwh: num(row, "energyKwh") ?? 0, - chargingLossPercent: num(row, "chargingLossPercent") ?? 10, - gridEnergyKwh: num(row, "gridEnergyKwh") ?? 0, - pricePerKwh: num(row, "pricePerKwh") ?? 0, - totalCost: num(row, "totalCost") ?? 0, - ...(row.isDemo === true ? { isDemo: true } : {}), - ...(startSocPercent === undefined ? {} : { startSocPercent }), - ...(endSocPercent === undefined ? {} : { endSocPercent }), - curve: [], - }; - }); - - // Curves for exactly these sessions: bounded by the oldest start and the newest end. - const start = new Date(Math.min(...sessions.map((s) => s.startedAt.getTime()))); - const stop = new Date(Math.max(...sessions.map((s) => s.endedAt.getTime())) + 1000); - const curveRows = await rows(flux` - from(bucket: ${bucket()}) - |> range(start: ${start}, stop: ${stop}) - |> filter(fn: (r) => r._measurement == "charging_curve" and r.installationId == ${installationId}) - |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")`); - const bySession = new Map(sessions.map((s) => [s.sessionId, s])); - for (const row of curveRows) { - const session = bySession.get(String(row.sessionId)); - const powerKw = num(row, "powerKw"); - if (!session || powerKw === undefined) continue; - const socPercent = num(row, "socPercent"); - const point: CurvePoint = { timestamp: new Date(row._time), powerKw }; - if (socPercent !== undefined) point.socPercent = socPercent; - session.curve.push(point); - } - for (const session of sessions) session.curve.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); - return sessions; -} - -/** Installations that reported in the last 400 days, the most recent first. */ -export async function installationIds(): Promise { - const result = await rows(flux` - from(bucket: ${bucket()}) - |> range(start: -400d) - |> filter(fn: (r) => r._measurement == "consumption_chunk" or r._measurement == "charging_session" or r._measurement == "telemetry") - |> keep(columns: ["_time", "installationId"]) - |> group(columns: ["installationId"]) - |> max(column: "_time") - |> group() - |> sort(columns: ["_time"], desc: true)`); - return result.map((row) => String(row.installationId)); -} - -/** - * The latest known value of every live field over the last 30 days, for the dashboard. - * Per field rather than per sample: the newest sample may lack a GPS fix that the one - * before it had. `at` is when the newest of them arrived. Null when nothing was sent. - */ -export async function readLatestTelemetry(installationId: string): Promise<(TelemetrySample & { at: Date }) | null> { - const result = await rows(flux` - from(bucket: ${bucket()}) - |> range(start: -30d) - |> filter(fn: (r) => r._measurement == "telemetry" and r.installationId == ${installationId}) - |> last()`); - if (result.length === 0) return null; - const newest = Math.max(...result.map((row) => Date.parse(row._time))); - const sample: TelemetrySample & { at: Date } = { utc: Math.floor(newest / 1000), at: new Date(newest) }; - const numbers = TELEMETRY_NUMBERS as Record; - const flags = TELEMETRY_FLAGS as readonly string[]; - for (const row of result) { - const field = String(row._field); - const value = row._value; - if (field in numbers && typeof value === "number") (sample as Record)[field] = value; - if (flags.includes(field) && typeof value === "boolean") (sample as Record)[field] = value; - } - return sample; -} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts deleted file mode 100644 index c523a46..0000000 --- a/web/src/lib/types.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** Record shapes shared by validation, storage and the dashboard. */ - -export interface Totals { - km: number; - kwh: number; - hours: number; - socDrop: number; -} - -/** One day's consumption: the sum of every chunk the car sent for that date. */ -export interface DailyConsumption { - installationId: string; - /** The car's local date, YYYY-MM-DD. */ - date: string; - /** When the newest chunk of the day was recorded. */ - recordedAt: Date; - day: Totals; - /** Running total up to and including this day, derived when history is read. */ - lifetime?: Totals; - sohPercent?: number; -} - -/** One 2-minute delta chunk as the car sends it; days are built from these. */ -export interface ConsumptionChunk { - installationId: string; - date: string; - recordedAt: Date; - day: Totals; - sohPercent?: number; -} - -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[]; -} - -/** Numeric live-telemetry fields, named and unit-ed as ABRP's tlm/send expects them. */ -export const TELEMETRY_NUMBERS = { - 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], - tire_pressure_rl: [0, 1000], - tire_pressure_rr: [0, 1000], -} as const satisfies Record; - -/** Boolean live-telemetry fields; ABRP takes them as 0/1. */ -export const TELEMETRY_FLAGS = ["is_charging", "is_dcfc", "is_parked"] as const; - -export type TelemetryNumber = keyof typeof TELEMETRY_NUMBERS; -export type TelemetryFlag = (typeof TELEMETRY_FLAGS)[number]; - -/** One live sample. Only `utc` is always present; an unread signal is absent, never 0. */ -export type TelemetrySample = { utc: number } & Partial> & - Partial>; - -export interface TelemetryBatch { - installationId: string; - samples: TelemetrySample[]; -} diff --git a/web/src/lib/validate.ts b/web/src/lib/validate.ts deleted file mode 100644 index 945657e..0000000 --- a/web/src/lib/validate.ts +++ /dev/null @@ -1,167 +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 { - TELEMETRY_FLAGS, - TELEMETRY_NUMBERS, - type ChargingSession, - type ConsumptionChunk, - type CurvePoint, - type TelemetryBatch, - type TelemetrySample, - type Totals, -} from "./types.ts"; - -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 function validateConsumption(body: unknown): ConsumptionChunk { - 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; - // A lifetime from an older app is still validated, so a malformed one is still a 400, - // but it is not stored: the backend derives lifetime from the day totals. - if (record.lifetime !== undefined) validateTotals(record.lifetime, "lifetime"); - return { - installationId: validateUuid(record.installationId, "installationId"), - date, - recordedAt: validateDate(record.recordedAt, "recordedAt"), - day: validateTotals(record.day, "day"), - ...(sohPercent === undefined ? {} : { sohPercent: bounded(sohPercent, 0, 100, "sohPercent") }), - }; -} - -export function validateChargingSession(body: unknown, pathSessionId: string): ChargingSession { - 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, - }; -} - -const MAX_SAMPLES = 500; -/** Epoch seconds: 2001 to 2286. Milliseconds would be far above the upper bound. */ -const UTC_MIN = 1_000_000_000; -const UTC_MAX = 9_999_999_999; - -/** - * A batch of live samples in ABRP's tlm field names and units. Fields the server does not - * know are dropped rather than rejected, so a newer app can add one without a server - * release; every known field is bounded, and a flag may be sent as 0/1 or a boolean. - */ -export function validateTelemetryBatch(body: unknown): TelemetryBatch { - const record = asObject(body); - const installationId = validateUuid(record.installationId, "installationId"); - const samples = record.samples; - if (!Array.isArray(samples) || samples.length === 0 || samples.length > MAX_SAMPLES) { - throw new ValidationError("samples is invalid"); - } - return { installationId, samples: samples.map((entry, i) => validateSample(entry, `samples[${i}]`)) }; -} - -function validateSample(entry: unknown, name: string): TelemetrySample { - const raw = asObject(entry); - const utc = raw.utc; - if (typeof utc !== "number" || !Number.isSafeInteger(utc) || utc < UTC_MIN || utc > UTC_MAX) { - throw new ValidationError(`${name}.utc is invalid`); - } - const sample: TelemetrySample = { utc }; - for (const [field, [min, max]] of Object.entries(TELEMETRY_NUMBERS)) { - const value = raw[field]; - if (value === undefined || value === null) continue; - (sample as Record)[field] = bounded(value, min, max, `${name}.${field}`); - } - for (const field of TELEMETRY_FLAGS) { - const value = raw[field]; - if (value === undefined || value === null) continue; - if (value === true || value === 1) sample[field] = true; - else if (value === false || value === 0) sample[field] = false; - else throw new ValidationError(`${name}.${field} is invalid`); - } - // A position is only a position as a pair. - if ((sample.lat === undefined) !== (sample.lon === undefined)) { - throw new ValidationError(`${name}.lat and lon must be sent together`); - } - return sample; -} - -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/days.test.ts b/web/test/days.test.ts deleted file mode 100644 index 6fb6486..0000000 --- a/web/test/days.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { aggregateDays } from "../src/lib/days.ts"; - -const ID = "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b"; -const chunk = (date: string, at: number, km: number, kwh: number, sohPercent?: number) => ({ - installationId: ID, - date, - recordedAt: new Date(at), - day: { km, kwh, hours: km / 50, socDrop: kwh / 0.6 }, - ...(sohPercent === undefined ? {} : { sohPercent }), -}); - -test("adds chunks up per day, newest day first, with a running lifetime", () => { - const days = aggregateDays([ - chunk("2026-10-16", 1_000, 10, 2), - chunk("2026-10-17", 3_000, 5, 1), - chunk("2026-10-16", 2_000, 20, 3), - ]); - assert.deepEqual(days.map((d) => d.date), ["2026-10-17", "2026-10-16"]); - assert.equal(days[1].day.km, 30); - assert.equal(days[1].day.kwh, 5); - assert.equal(days[1].recordedAt.getTime(), 2_000); - assert.equal(days[1].lifetime?.km, 30); - assert.equal(days[0].lifetime?.km, 35); - assert.equal(days[0].lifetime?.kwh, 6); -}); - -test("the newest manual SoH of a day wins, and a day without one has none", () => { - const days = aggregateDays([ - chunk("2026-10-16", 2_000, 1, 1, 96), - chunk("2026-10-16", 1_000, 1, 1, 98), - chunk("2026-10-17", 3_000, 1, 1), - ]); - assert.equal(days[1].sohPercent, 96); - assert.equal("sohPercent" in days[0], false); -}); 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/telemetry.test.ts b/web/test/telemetry.test.ts deleted file mode 100644 index 7adea8a..0000000 --- a/web/test/telemetry.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { pickForForward, resetAbrpState, toTlm } from "../src/lib/abrp.ts"; -import { ValidationError, validateTelemetryBatch } from "../src/lib/validate.ts"; - -const INSTALLATION = "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b"; -const NOW_S = 1_790_000_000; - -test("accepts a sample in ABRP's field names and keeps only what was sent", () => { - const batch = validateTelemetryBatch({ - installationId: INSTALLATION, - samples: [{ utc: NOW_S, soc: 81, power: -12.5, speed: 0, lat: 52.1, lon: 7.6, is_charging: 1, is_dcfc: false }], - }); - assert.deepEqual(batch.samples[0], { - utc: NOW_S, soc: 81, power: -12.5, speed: 0, lat: 52.1, lon: 7.6, is_charging: true, is_dcfc: false, - }); - // An unread signal stays absent: a missing SOC must never become 0. - assert.equal("est_battery_range" in batch.samples[0], false); -}); - -test("drops unknown fields instead of rejecting the batch", () => { - const batch = validateTelemetryBatch({ installationId: INSTALLATION, samples: [{ utc: NOW_S, future_field: 3 }] }); - assert.deepEqual(batch.samples[0], { utc: NOW_S }); -}); - -test("rejects bad timestamps, out-of-range values, bad flags and half positions", () => { - const send = (sample: object) => validateTelemetryBatch({ installationId: INSTALLATION, samples: [sample] }); - assert.throws(() => send({ utc: NOW_S * 1000 }), ValidationError); // milliseconds, not seconds - assert.throws(() => send({ utc: 1.5 }), ValidationError); - assert.throws(() => send({ utc: NOW_S, soc: 101 }), ValidationError); - assert.throws(() => send({ utc: NOW_S, speed: "fast" }), ValidationError); - assert.throws(() => send({ utc: NOW_S, is_parked: 2 }), ValidationError); - assert.throws(() => send({ utc: NOW_S, lat: 52.1 }), ValidationError); -}); - -test("rejects empty and oversized batches and bad installation ids", () => { - assert.throws(() => validateTelemetryBatch({ installationId: INSTALLATION, samples: [] }), ValidationError); - const many = Array.from({ length: 501 }, (_, i) => ({ utc: NOW_S + i })); - assert.throws(() => validateTelemetryBatch({ installationId: INSTALLATION, samples: many }), ValidationError); - assert.throws(() => validateTelemetryBatch({ installationId: "nope", samples: [{ utc: NOW_S }] }), ValidationError); -}); - -test("ABRP gets flags as 0/1", () => { - assert.deepEqual(toTlm({ utc: NOW_S, soc: 50, is_charging: true, is_parked: false }), { - utc: NOW_S, soc: 50, is_charging: 1, is_parked: 0, - }); -}); - -test("forwards only the newest fresh sample, at most once per interval", () => { - resetAbrpState(); - const nowMs = NOW_S * 1000; - const picked = pickForForward([{ utc: NOW_S - 10 }, { utc: NOW_S - 2 }, { utc: NOW_S - 5 }], nowMs); - assert.equal(picked?.utc, NOW_S - 2); - // A sample from ten minutes ago describes a car that has moved on. - assert.equal(pickForForward([{ utc: NOW_S - 600 }], nowMs), null); -}); 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 deb57ab..0000000 --- a/web/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["dom", "dom.iterable", "ES2022"], - "allowJs": false, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "allowImportingTsExtensions": 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"] -}