Server: rewrite in Go as an API-only service

Same endpoints, request formats, validation rules and InfluxDB layout as
the Next.js server, so the car needs no change: POST /v1/telemetry,
PUT /v1/consumption/daily, PUT /v1/charging-sessions/{id}, GET
/v1/consumption/history/{id}, GET /v1/health. The dashboard, login and
the /v1/history reads are gone; Grafana takes over the dashboards.

- Go 1.26 standard library (net/http routing, log/slog) plus the
  official InfluxDB 2 client. Fails closed at startup on a missing
  InfluxDB setting or an ingest token under 24 characters.
- ABRP forwarder ported: newest fresh sample, rate limited, never
  queued, credentials only in the form body.
- 22 tests: validation, aggregation, forwarding, config, and the
  handlers through a fake store. govulncheck clean (x/net 0.60.0,
  toolchain go1.26.9).
- Image: static binary on distroless, about 11 MB, UID 65532, port
  8080; the build runs vet and the tests.
- Dev shell: Go, gopls and govulncheck instead of Node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
This commit is contained in:
2026-10-17 15:17:42 +02:00
co-authored by Claude Opus 5.5
parent 538d03361d
commit 51e6f6fd48
82 changed files with 1673 additions and 3966 deletions
-1
View File
@@ -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}}]
-1
View File
@@ -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}}]
-15
View File
@@ -1,15 +0,0 @@
{
"pages": {
"/_app": []
},
"devFiles": [],
"polyfillFiles": [],
"lowPriorityFiles": [
"static/development/_buildManifest.js",
"static/development/_ssgManifest.js",
"static/development/_clientMiddlewareManifest.js"
],
"rootMainFiles": [],
"rootMainFilesTree": {},
"pagesChunkGroupBootstrapParams": {}
}
-1
View File
@@ -1 +0,0 @@
{"encryption.key":"UT9PfJliySgklhhPCIM4wpEhmx9PMD9GPB4iuPIamyM=","encryption.expire_at":1792761221411}
-1
View File
@@ -1 +0,0 @@
{"max_sequence_number":0,"commit_time":"2026-10-09T13:13:41.418251Z"}
-15
View File
@@ -1,15 +0,0 @@
{
"pages": {
"/_app": []
},
"devFiles": [],
"polyfillFiles": [],
"lowPriorityFiles": [
"static/development/_buildManifest.js",
"static/development/_ssgManifest.js",
"static/development/_clientMiddlewareManifest.js"
],
"rootMainFiles": [],
"rootMainFilesTree": {},
"pagesChunkGroupBootstrapParams": {}
}
-1
View File
@@ -1 +0,0 @@
{"pid":79123,"port":3001,"hostname":"localhost","appUrl":"http://localhost:3001","startedAt":1791551621304}
-3
View File
@@ -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."}
-3
View File
@@ -1,3 +0,0 @@
{
"type": "commonjs"
}
-6
View File
@@ -1,6 +0,0 @@
{
"version": 4,
"routes": {},
"dynamicRoutes": {},
"notFoundRoutes": []
}
-1
View File
@@ -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":[]}
-1
View File
@@ -1 +0,0 @@
{}
@@ -1 +0,0 @@
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]";
@@ -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": {}
};
@@ -1,6 +0,0 @@
{
"version": 3,
"middleware": {},
"sortedMiddleware": [],
"functions": {}
}
-1
View File
@@ -1 +0,0 @@
self.__NEXT_FONT_MANIFEST="{\n \"app\": {},\n \"appUsingSizeAdjust\": false,\n \"pages\": {},\n \"pagesUsingSizeAdjust\": false\n}"
-6
View File
@@ -1,6 +0,0 @@
{
"app": {},
"appUsingSizeAdjust": false,
"pages": {},
"pagesUsingSizeAdjust": false
}
-1
View File
@@ -1 +0,0 @@
{}
-5
View File
@@ -1,5 +0,0 @@
{
"previewModeId": "a508c818777951bed52c0170453ef7e3",
"previewModeSigningKey": "591a19b72d1a845a81057e56fb15a2ced8409045c0ed580013f217b8c4a4ea61",
"previewModeEncryptionKey": "ded0359fbc3be263ec7ff240e7fb98fc5b836958070834f5bc954f1092afa91c"
}
@@ -1 +0,0 @@
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"UT9PfJliySgklhhPCIM4wpEhmx9PMD9GPB4iuPIamyM=\"\n}"
@@ -1,5 +0,0 @@
{
"node": {},
"edge": {},
"encryptionKey": "UT9PfJliySgklhhPCIM4wpEhmx9PMD9GPB4iuPIamyM="
}
@@ -1,8 +0,0 @@
self.__BUILD_MANIFEST = {
"__rewrites": {
"afterFiles": [],
"beforeFiles": [],
"fallback": []
},
"sortedPages": []
};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()
@@ -1 +0,0 @@
self.__MIDDLEWARE_MATCHERS = [];self.__MIDDLEWARE_MATCHERS_CB && self.__MIDDLEWARE_MATCHERS_CB()
@@ -1 +0,0 @@
self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
-55
View File
@@ -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<Route extends Routes> = 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 <div>Blog post: {slug}</div>
* }
* ```
*/
interface PageProps<AppRoute extends AppRoutes> {
params: Promise<ParamMap[AppRoute]>
searchParams: Promise<Record<string, string | string[] | undefined>>
}
/**
* Props for Next.js App Router layout components
* @example
* ```tsx
* export default function Layout(props: LayoutProps<'/dashboard'>) {
* return <div>{props.children}</div>
* }
* ```
*/
type LayoutProps<LayoutRoute extends LayoutRoutes> = {
params: Promise<ParamMap[LayoutRoute]>
children: React.ReactNode
} & {
[K in LayoutSlotMap[LayoutRoute]]: React.ReactNode
}
}
+1 -1
View File
@@ -112,7 +112,7 @@
in in
{ {
default = pkgs.mkShell { 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_HOME = sdk;
ANDROID_SDK_ROOT = sdk; ANDROID_SDK_ROOT = sdk;
+4
View File
@@ -0,0 +1,4 @@
*
!go.mod
!go.sum
!*.go
+16
View File
@@ -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"]
+77
View File
@@ -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 <INGEST_TOKEN>`.
- `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:<version> .
podman push git.stinnesbeck.com/nils/mg4-server:<version>
```
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.
+139
View File
@@ -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
}
+157
View File
@@ -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)
}
}
+170
View File
@@ -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)
}
+51
View File
@@ -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
}
+15
View File
@@ -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
)
+27
View File
@@ -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=
+121
View File
@@ -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")
}
}
+113
View File
@@ -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)
}
}
+108
View File
@@ -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
}
+162
View File
@@ -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
}
+327
View File
@@ -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
}
+185
View File
@@ -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")
}
}
-7
View File
@@ -1,7 +0,0 @@
node_modules
.next
.env*
!.env.example
*.tsbuildinfo
Dockerfile
.dockerignore
-18
View File
@@ -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
-7
View File
@@ -1,7 +0,0 @@
node_modules
.next
# Every .env variant is secret except the committed template.
.env*
!.env.example
next-env.d.ts
*.tsbuildinfo
-27
View File
@@ -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"]
-88
View File
@@ -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 <INGEST_TOKEN>`:
- `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 <DASHBOARD_PASSWORD>`.
`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:<version> .
podman push git.stinnesbeck.com/nils/mg4-server:<version>
```
The build runs the unit tests. The image runs as UID 10001 and listens on port 3000.
-8
View File
@@ -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;
-1002
View File
File diff suppressed because it is too large Load Diff
-27
View File
@@ -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"
}
-24
View File
@@ -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;
}
-100
View File
@@ -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 (
<Shell active="/charging" title="Charging">
<div className="card">
<p className="empty">
No charging sessions yet. Only DC charges above 10 kW are metered; AC charging is not recorded.
</p>
</div>
</Shell>
);
}
const total = sumCharging(sessions);
return (
<Shell active="/charging" title="Charging" subtitle={`${sessions.length} DC session${sessions.length === 1 ? "" : "s"}`}>
<div className="tiles">
<Tile label="Net into battery" value={precise(total.energyKwh)} unit="kWh" tone="gold" />
<Tile label="Drawn from grid (+10%)" value={precise(total.gridEnergyKwh)} unit="kWh" tone="gold" />
<Tile label="Total paid" value={money(total.cost)} tone="gold" />
<Tile
label="Average price"
value={total.gridEnergyKwh > 0 ? money(total.cost / total.gridEnergyKwh) : "—"}
note="per kWh"
tone="teal"
/>
<Tile label="Peak power" value={num(total.peakKw)} unit="kW" tone="teal" />
</div>
<section className="card">
<h2 className="card-title">Sessions</h2>
<ul className="list">
{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 (
<li key={session.sessionId}>
<details className="session">
<summary>
<div>
<div className="session-when">{dateTime(session.startedAt)}</div>
<div className="session-meta">
{session.isDemo && "Demo · "}
{duration(session.durationSeconds)}
{gained !== null && ` · ${num(session.startSocPercent as number)}% → ${num(session.endSocPercent as number)}%`}
</div>
</div>
<div className="session-energy">
{precise(session.energyKwh)}
<span className="session-cost"> kWh</span>
<div className="session-cost">{money(session.totalCost)}</div>
</div>
</summary>
<div className="session-body">
<ChargeCurveChart
curve={session.curve.map((point) => ({
timestamp: new Date(point.timestamp).toISOString(),
powerKw: point.powerKw,
socPercent: point.socPercent,
}))}
/>
<div className="rows" style={{ marginTop: 14 }}>
<Row label="Start" value={dateTime(session.startedAt)} />
<Row label="End" value={dateTime(session.endedAt)} />
<Row label="Metered duration" value={duration(session.durationSeconds)} />
<Row label="Net into battery" value={`${precise(session.energyKwh)} kWh`} />
<Row label="Drawn from grid (+10%)" value={`${precise(session.gridEnergyKwh ?? session.energyKwh * 1.1)} kWh`} />
<Row label="Charge added" value={gained === null ? "—" : `${num(gained)}%`} />
<Row label="Peak power" value={peak > 0 ? `${num(peak)} kW` : "—"} />
<Row label="Average power" value={average > 0 ? `${num(average)} kW` : "—"} />
<Row label="Unit price" value={`${money(session.pricePerKwh)}/kWh`} />
<Row label="Amount" value={money(session.totalCost)} />
</div>
</div>
</details>
</li>
);
})}
</ul>
</section>
</Shell>
);
}
-178
View File
@@ -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 (
<Shell active="/consumption" title="Consumption">
<div className="card">
<p className="empty">No daily consumption records yet. The car uploads them as it drives.</p>
</div>
</Shell>
);
}
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 (
<Shell
active="/consumption"
title="Consumption"
subtitle={hasFilter ? (validFilter ? isoDate(selectedDate!) : "No record for the selected day") : `${days.length} day${days.length === 1 ? "" : "s"} recorded`}
>
{/* Date filter */}
<section className="card">
<form className="filter-form" method="get" action="/consumption">
<label className="filter-label" htmlFor="date-picker">
Filter by date
</label>
<div className="filter-row">
<input
id="date-picker"
type="date"
name="date"
defaultValue={selectedDate ?? ""}
min={minDate}
max={maxDate}
className="field date-input"
/>
<button className="button filter-button" type="submit">Filter</button>
{hasFilter && (
<Link className="filter-clear" href="/consumption">Clear</Link>
)}
</div>
</form>
</section>
{/* Selected day detail — shown above the period tiles when a filter is active */}
{hasFilter && (
<section className="card">
<h2 className="card-title">{isoDate(selectedDate!)}</h2>
{validFilter && selected ? (
<>
<div className="tiles">
<Tile label="Distance" value={num(selected.day.km)} unit="km" tone="teal" />
<Tile label="Energy" value={precise(selected.day.kwh)} unit="kWh" tone="gold" />
<Tile label="Average" value={efficiency(selected.day.kwh, selected.day.km)} tone="teal" />
<Tile
label="Average speed"
value={selected.day.hours > 0 ? num(selected.day.km / selected.day.hours) : "—"}
unit={selected.day.hours > 0 ? "km/h" : undefined}
/>
</div>
<div className="rows">
<Row label="Driving time" value={duration(selected.day.hours * 3600)} />
<Row label="SoC used" value={`${num(selected.day.socDrop)}%`} />
<Row label="Lifetime at end of day" value={selected.lifetime ? `${num(selected.lifetime.km)} km` : "—"} />
</div>
</>
) : (
<p className="empty">No consumption recorded for this date.</p>
)}
</section>
)}
<div className="tiles">
<Tile label="Total distance" value={num(total.km)} unit="km" tone="teal" />
<Tile label="Total energy" value={precise(total.kwh)} unit="kWh" tone="gold" />
<Tile label="Average" value={efficiency(total.kwh, total.km)} tone="teal" />
<Tile
label="Average speed"
value={total.hours > 0 ? num(total.km / total.hours) : "—"}
unit={total.hours > 0 ? "km/h" : undefined}
/>
</div>
<section className="card">
<h2 className="card-title">Daily energy</h2>
<DailyEnergyChart days={chartDays} />
</section>
<section className="card">
<h2 className="card-title">Efficiency trend</h2>
<EfficiencyChart days={chartDays} />
</section>
<section className="card">
<h2 className="card-title">Lifetime</h2>
<div className="rows">
<Row label="Distance" value={`${num(lifetime.km)} km`} />
<Row label="Energy" value={`${precise(lifetime.kwh)} kWh`} />
<Row label="Average" value={efficiency(lifetime.kwh, lifetime.km)} />
<Row label="Driving time" value={duration(lifetime.hours * 3600)} />
</div>
</section>
<section className="card">
<h2 className="card-title">Day by day</h2>
<ul className="list">
{table.map((day) => {
const isSelected = hasFilter && day.date === selectedDate;
return (
<li key={day.date}>
<details className={`session${isSelected ? " selected" : ""}`} open={isSelected}>
<summary>
<div>
<div className="session-when">
<Link href={`/consumption?date=${day.date}`} className="day-link">
{isoDate(day.date)}
</Link>
</div>
<div className="session-meta">
{num(day.day.km)} km · {efficiency(day.day.kwh, day.day.km)}
</div>
</div>
<div className="session-energy">
{precise(day.day.kwh)}
<span className="session-cost"> kWh</span>
</div>
</summary>
<div className="session-body">
<div className="rows">
<Row label="Driving time" value={duration(day.day.hours * 3600)} />
<Row
label="Average speed"
value={day.day.hours > 0 ? `${num(day.day.km / day.day.hours)} km/h` : "—"}
/>
<Row label="SoC used" value={`${num(day.day.socDrop)}%`} />
<Row label="Lifetime at end of day" value={day.lifetime ? `${num(day.lifetime.km)} km` : "—"} />
</div>
</div>
</details>
</li>
);
})}
</ul>
</section>
</Shell>
);
}
-430
View File
@@ -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);
}
-23
View File
@@ -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 (
<html lang="en">
<body>{children}</body>
</html>
);
}
-48
View File
@@ -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 (
<div className="login">
<form className="login-card" action="/api/session" method="post">
<h1 className="title">Nils&apos; MG4</h1>
<p className="subtitle">Consumption and charging history</p>
{/*
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.
*/}
<input
type="text"
name="username"
value="nils"
autoComplete="username"
readOnly
hidden
/>
<input
className="field"
type="password"
name="password"
placeholder="Password"
autoComplete="current-password"
autoFocus
required
/>
<button className="button" type="submit">
Sign in
</button>
{error && <p className="error">Wrong password.</p>}
</form>
</div>
);
}
-22
View File
@@ -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 `<Link>`, 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;
}
-99
View File
@@ -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 (
<Shell active="/" title="Overview">
<div className="card">
<p className="empty">
No data yet. It appears here once the car sends its first daily record.
</p>
</div>
</Shell>
);
}
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 (
<Shell
active="/"
title="Overview"
subtitle={installationCount > 1 ? `Most recent of ${installationCount} cars` : "MG4"}
>
<div className="tiles">
<Tile
label="Drawn from grid"
value={precise(charge.gridEnergyKwh)}
unit="kWh"
note={`${charge.sessions} DC charge${charge.sessions === 1 ? "" : "s"}`}
tone="gold"
/>
<Tile
label="Paid"
value={money(charge.cost)}
note={charge.gridEnergyKwh > 0 ? `${money(charge.cost / charge.gridEnergyKwh)}/kWh avg.` : undefined}
tone="gold"
/>
<Tile label="Last 30 days" value={num(recent.km)} unit="km" note={efficiency(recent.kwh, recent.km)} tone="teal" />
<Tile
label="Lifetime"
value={lifetime ? num(lifetime.km) : "—"}
unit="km"
note={lifetime ? efficiency(lifetime.kwh, lifetime.km) : undefined}
tone="teal"
/>
</div>
<section className="card">
<h2 className="card-title">Last 30 days</h2>
<DailyEnergyChart days={days.map((day) => ({ date: day.date, kwh: day.day.kwh, km: day.day.km }))} />
</section>
{lastSession && (
<section className="card">
<h2 className="card-title">Last charge</h2>
<div className="rows">
<Row label="Date" value={dateTime(lastSession.startedAt)} />
<Row label="Net into battery" value={`${precise(lastSession.energyKwh)} kWh`} />
<Row label="Drawn from grid (+10%)" value={`${precise(lastSession.gridEnergyKwh ?? lastSession.energyKwh * 1.1)} kWh`} />
<Row
label="State of charge"
value={
lastSession.startSocPercent !== undefined && lastSession.endSocPercent !== undefined
? `${num(lastSession.startSocPercent)}% → ${num(lastSession.endSocPercent)}%`
: "—"
}
/>
<Row label="Duration" value={duration(lastSession.durationSeconds)} />
<Row label="Cost" value={money(lastSession.totalCost)} />
</div>
</section>
)}
<section className="card">
<h2 className="card-title">Battery and totals</h2>
<div className="rows">
<Row label="Health (SoH)" value={soh === undefined ? "—" : `${num(soh)}%`} />
<Row label="Total charging time" value={duration(charge.seconds)} />
<Row label="Highest power seen" value={charge.peakKw > 0 ? `${num(charge.peakKw)} kW` : "—"} />
<Row label="Charge added" value={charge.socGained > 0 ? `${num(charge.socGained)}%` : "—"} />
<Row label="Days recorded" value={`${days.length}`} />
</div>
</section>
</Shell>
);
}
@@ -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 });
});
}
-21
View File
@@ -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 });
});
}
@@ -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 });
});
}
-9
View File
@@ -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" });
}
@@ -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 });
});
}
@@ -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 });
});
}
-24
View File
@@ -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 });
});
}
-267
View File
@@ -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 (
<g>
{lines.map((fraction) => {
const y = plot.padTop + innerHeight(plot) * (1 - fraction);
return (
<g key={fraction}>
<line
x1={plot.padLeft}
x2={plot.width - plot.padRight}
y1={y}
y2={y}
stroke={GRID}
strokeWidth={1}
strokeDasharray={fraction === 0 ? undefined : "2 4"}
/>
<text x={plot.padLeft - 6} y={y + 3.5} fill={MUTED} fontSize={9} textAnchor="end">
{format(max * fraction)}
</text>
</g>
);
})}
</g>
);
}
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 <p className="empty">No daily records yet</p>;
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 (
<>
<svg className="chart" viewBox={`0 0 ${plot.width} ${plot.height}`} role="img"
aria-label="Energy used per day">
<Grid plot={plot} max={max} format={(value) => 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 (
<rect
key={day.date}
x={x}
y={plot.padTop + innerHeight(plot) - height}
width={barWidth}
height={Math.max(height, day.kwh > 0 ? 1 : 0)}
rx={2}
fill={GOLD}
opacity={0.92}
>
<title>{`${day.date} — ${day.kwh.toFixed(2)} kWh, ${day.km.toFixed(1)} km`}</title>
</rect>
);
})}
<text x={plot.padLeft} y={plot.height - 6} fill={MUTED} fontSize={9}>
{shortDate(days[0].date)}
</text>
<text x={plot.width - plot.padRight} y={plot.height - 6} fill={MUTED} fontSize={9} textAnchor="end">
{shortDate(days[days.length - 1].date)}
</text>
</svg>
<div className="legend">
<span>
<span className="swatch" style={{ background: GOLD }} />
Daily energy (kWh)
</span>
</div>
</>
);
}
/** 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 <p className="empty">Not enough days for a chart</p>;
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 (
<>
<svg className="chart" viewBox={`0 0 ${plot.width} ${plot.height}`} role="img"
aria-label="Average consumption per day">
<Grid plot={plot} max={max} format={(value) => value.toFixed(0)} />
<line
x1={plot.padLeft}
x2={plot.width - plot.padRight}
y1={averageY}
y2={averageY}
stroke={GOLD}
strokeWidth={1}
strokeDasharray="4 4"
opacity={0.7}
/>
<path d={path} fill="none" stroke={CYAN} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
{points.map((point, index) => (
<circle
key={point.date}
cx={plot.padLeft + step * index}
cy={plot.padTop + innerHeight(plot) * (1 - point.value / max)}
r={1.8}
fill={CYAN}
>
<title>{`${point.date} — ${point.value.toFixed(1)} kWh/100 km`}</title>
</circle>
))}
</svg>
<div className="legend">
<span>
<span className="swatch" style={{ background: CYAN }} />
kWh/100 km
</span>
<span>
<span className="swatch" style={{ background: GOLD }} />
Average {average.toFixed(1)}
</span>
</div>
</>
);
}
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 <p className="empty">No curve recorded for this session</p>;
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 (
<>
<svg className="chart" viewBox={`0 0 ${plot.width} ${plot.height}`} role="img"
aria-label="Charging power and state-of-charge curve">
<Grid plot={plot} max={maxPower} format={(value) => value.toFixed(0)} />
<defs>
<linearGradient id="powerFill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={GOLD} stopOpacity={0.32} />
<stop offset="100%" stopColor={GOLD} stopOpacity={0.02} />
</linearGradient>
</defs>
<path d={areaPath} fill="url(#powerFill)" stroke="none" />
<path d={powerPath} fill="none" stroke={GOLD} strokeWidth={2} strokeLinejoin="round" />
{socPath && <path d={socPath} fill="none" stroke={CYAN} strokeWidth={1.6} strokeDasharray="5 3" />}
<text x={plot.width - plot.padRight + 4} y={plot.padTop + 4} fill={MUTED} fontSize={9}>
100%
</text>
<text x={plot.width - plot.padRight + 4} y={plot.padTop + innerHeight(plot)} fill={MUTED} fontSize={9}>
0%
</text>
<text x={plot.padLeft} y={plot.height - 6} fill={MUTED} fontSize={9}>
0 min
</text>
<text x={plot.width - plot.padRight} y={plot.height - 6} fill={MUTED} fontSize={9} textAnchor="end">
{Math.round(span / 60000)} min
</text>
</svg>
<div className="legend">
<span>
<span className="swatch" style={{ background: GOLD }} />
Power (kW)
</span>
<span>
<span className="swatch" style={{ background: CYAN }} />
State of charge (%)
</span>
</div>
</>
);
}
function shortDate(iso: string): string {
const [, month, day] = iso.split("-");
return `${day}.${month}`;
}
-99
View File
@@ -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 (
<>
<main className="shell">
<header className="header">
<div>
<h1 className="title">{title}</h1>
{subtitle && <p className="subtitle">{subtitle}</p>}
</div>
{/*
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.
*/}
<form action="/logout" method="post">
<button className="signout" type="submit">
Sign out
</button>
</form>
</header>
{children}
</main>
<nav className="nav">
<div className="nav-inner">
{TABS.map((tab) => (
<Link key={tab.href} href={tab.href} aria-current={tab.href === active ? "page" : undefined}>
<span className="nav-icon" aria-hidden>
{tab.icon}
</span>
{tab.label}
</Link>
))}
</div>
</nav>
</>
);
}
export function Tile({
label,
value,
unit,
note,
tone,
}: {
label: string;
value: string;
unit?: string;
note?: string;
tone?: "gold" | "teal";
}) {
return (
<div className={`tile${tone ? ` ${tone}` : ""}`}>
<p className="tile-label">{label}</p>
<p className="tile-value">
{value}
{unit && <span className="tile-unit">{unit}</span>}
</p>
{note && <p className="tile-note">{note}</p>}
</div>
);
}
export function Row({ label, value }: { label: string; value: string }) {
return (
<div className="row">
<span className="row-key">{label}</span>
<span className="row-value">{value}</span>
</div>
);
}
-88
View File
@@ -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<string, number> {
const tlm: Record<string, number> = {};
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<void> {
// 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;
}
-53
View File
@@ -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<NextResponse>): Promise<NextResponse> {
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<NextResponse | null> {
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<unknown> {
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");
}
}
-20
View File
@@ -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<boolean> {
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<void> {
if (!(await hasSession())) redirect("/login");
}
-83
View File
@@ -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<Overview> {
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<ChargeTotals>(
(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 },
);
}
-43
View File
@@ -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<string, DailyConsumption & { sohAt: number }>();
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();
}
-48
View File
@@ -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`;
}
-40
View File
@@ -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"));
}
-80
View File
@@ -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);
}
/**
* `<expiry>.<hmac>` — 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;
-225
View File
@@ -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<void> {
const api = writeApi();
api.writePoints(points);
await api.close();
}
export async function writeConsumptionChunk(chunk: ConsumptionChunk): Promise<void> {
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<void> {
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<void> {
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<string, unknown> & { _time: string };
async function rows(query: ReturnType<typeof flux>): Promise<Row[]> {
return queryApi().collectRows<Row>(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<ConsumptionChunk[]> {
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<DailyConsumption[]> {
return aggregateDays(await readConsumptionChunks(installationId));
}
export async function readChargingSessions(installationId: string, limit: number): Promise<ChargingSession[]> {
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<string[]> {
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<string, unknown>;
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<string, unknown>)[field] = value;
if (flags.includes(field) && typeof value === "boolean") (sample as Record<string, unknown>)[field] = value;
}
return sample;
}
-96
View File
@@ -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<string, readonly [number, number]>;
/** 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<Record<TelemetryNumber, number>> &
Partial<Record<TelemetryFlag, boolean>>;
export interface TelemetryBatch {
installationId: string;
samples: TelemetrySample[];
}
-167
View File
@@ -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<string, unknown>)[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<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ValidationError("body is required");
}
return value as Record<string, unknown>;
}
/** 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;
}
-37
View File
@@ -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);
});
-44
View File
@@ -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);
});
-56
View File
@@ -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);
});
-99
View File
@@ -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,
);
});
-22
View File
@@ -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"]
}