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:
@@ -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 +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}}]
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"pages": {
|
||||
"/_app": []
|
||||
},
|
||||
"devFiles": [],
|
||||
"polyfillFiles": [],
|
||||
"lowPriorityFiles": [
|
||||
"static/development/_buildManifest.js",
|
||||
"static/development/_ssgManifest.js",
|
||||
"static/development/_clientMiddlewareManifest.js"
|
||||
],
|
||||
"rootMainFiles": [],
|
||||
"rootMainFilesTree": {},
|
||||
"pagesChunkGroupBootstrapParams": {}
|
||||
}
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
{"encryption.key":"UT9PfJliySgklhhPCIM4wpEhmx9PMD9GPB4iuPIamyM=","encryption.expire_at":1792761221411}
|
||||
@@ -1 +0,0 @@
|
||||
{"max_sequence_number":0,"commit_time":"2026-10-09T13:13:41.418251Z"}
|
||||
@@ -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 +0,0 @@
|
||||
{"pid":79123,"port":3001,"hostname":"localhost","appUrl":"http://localhost:3001","startedAt":1791551621304}
|
||||
@@ -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."}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"type": "commonjs"
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"version": 4,
|
||||
"routes": {},
|
||||
"dynamicRoutes": {},
|
||||
"notFoundRoutes": []
|
||||
}
|
||||
@@ -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 +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 +0,0 @@
|
||||
self.__NEXT_FONT_MANIFEST="{\n \"app\": {},\n \"appUsingSizeAdjust\": false,\n \"pages\": {},\n \"pagesUsingSizeAdjust\": false\n}"
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"app": {},
|
||||
"appUsingSizeAdjust": false,
|
||||
"pages": {},
|
||||
"pagesUsingSizeAdjust": false
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -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()
|
||||
Vendored
-55
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,7 @@
|
||||
in
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
packages = [ jdk android.androidsdk buildCar pkgs.nodejs_22 pkgs.influxdb2-server pkgs.influxdb2-cli ];
|
||||
packages = [ jdk android.androidsdk buildCar pkgs.go pkgs.gopls pkgs.govulncheck pkgs.influxdb2-server pkgs.influxdb2-cli ];
|
||||
|
||||
ANDROID_HOME = sdk;
|
||||
ANDROID_SDK_ROOT = sdk;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
*
|
||||
!go.mod
|
||||
!go.sum
|
||||
!*.go
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
node_modules
|
||||
.next
|
||||
.env*
|
||||
!.env.example
|
||||
*.tsbuildinfo
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -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
|
||||
@@ -1,7 +0,0 @@
|
||||
node_modules
|
||||
.next
|
||||
# Every .env variant is secret except the committed template.
|
||||
.env*
|
||||
!.env.example
|
||||
next-env.d.ts
|
||||
*.tsbuildinfo
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
Generated
-1002
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user