Server: rewrite in Go as an API-only service

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
This commit is contained in:
2026-10-17 15:17:42 +02:00
co-authored by Claude Opus 5.5
parent 538d03361d
commit 51e6f6fd48
82 changed files with 1673 additions and 3966 deletions
+4
View File
@@ -0,0 +1,4 @@
*
!go.mod
!go.sum
!*.go
+16
View File
@@ -0,0 +1,16 @@
# The car server: one static Go binary on distroless, running as non-root.
# Configuration comes from the environment at runtime; nothing secret is baked in.
FROM docker.io/library/golang:1.26.9-alpine AS build
WORKDIR /src
ENV CGO_ENABLED=0 GOFLAGS=-trimpath GOTOOLCHAIN=local
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY *.go ./
RUN go vet ./... && go test ./... && go build -ldflags="-s -w" -o /out/server .
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/server"]
+77
View File
@@ -0,0 +1,77 @@
# Nils' MG4 — car server
A small Go service at car.stinnesbeck.com: the API the car writes to, and the ABRP
forwarder. Data lives in InfluxDB 2; Grafana reads it from there for the dashboards
(`../k8s/grafana/`).
```
car ──live telemetry───▶ server ──▶ ABRP api.iternio.com/1/tlm/send
──daily chunks─────▶ │
──charging sessions▶ ▼
InfluxDB 2 ◀── Grafana
```
## Configuration
Environment only; the server refuses to start when a required value is missing.
| Name | Notes |
|---|---|
| `INFLUX_URL`, `INFLUX_TOKEN`, `INFLUX_ORG` | required; the token needs read/write on the bucket |
| `INFLUX_BUCKET` | default `mg4` |
| `INGEST_TOKEN` | required, at least 24 characters: `openssl rand -hex 32` |
| `ABRP_API_KEY`, `ABRP_USER_TOKEN` | optional; both needed to forward |
| `ABRP_MIN_INTERVAL_SECONDS` | default 5 |
| `LISTEN_ADDR` | default `:8080` |
The ABRP credentials never reach the car.
## Endpoints
All but `/v1/health` need `Authorization: Bearer <INGEST_TOKEN>`.
- `POST /v1/telemetry` — `{ installationId, samples: [...] }`, up to 500 samples in ABRP's `tlm`
field names and units (`utc` in epoch seconds; flags as 0/1 or booleans). Unknown fields are
dropped, known ones bounded. Stored, then the newest sample is forwarded to ABRP if it is under
5 minutes old.
- `PUT /v1/consumption/daily` — one delta chunk of a day's consumption.
- `PUT /v1/charging-sessions/{sessionId}` — a finished DC session with its curve.
- `GET /v1/consumption/history/{installationId}` — the car reads its own totals back.
- `GET /v1/health` — `{"status":"ok"}`.
A bad request is a 400 naming the field; a database failure is a 500 without detail.
## Storage
One bucket, `installationId` tag on every point:
| Measurement | Timestamp | Tags | Fields |
|---|---|---|---|
| `telemetry` | sample `utc` | — | the ABRP fields that were sent |
| `consumption_chunk` | `recordedAt` | `date` | km, kwh, hours, socDrop, sohPercent |
| `charging_session` | `startedAt` | `sessionId` | endedAtMs, duration, energy, cost, start/end SOC |
| `charging_curve` | point time | `sessionId` | powerKw, socPercent |
A point with the same measurement, tags and timestamp replaces the earlier one, so the car's
retries are idempotent: a cached chunk is re-sent with its original `recordedAt`. Days and the
lifetime total are summed from the chunks when read. Numbers are always written as floats,
because InfluxDB fixes a field's type on first write.
## Development
```sh
nix develop # from the repository root: Go, govulncheck, InfluxDB 2
cd server
go test ./...
govulncheck ./...
```
## Container image
```sh
podman build -t git.stinnesbeck.com/nils/mg4-server:<version> .
podman push git.stinnesbeck.com/nils/mg4-server:<version>
```
The build runs `go vet` and the tests. The image is a static binary on distroless (about
11 MB), running as UID 65532 on port 8080.
+139
View File
@@ -0,0 +1,139 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// Forwarder sends live telemetry on to A Better Route Planner.
//
// The ABRP credentials exist only here, from ABRP_API_KEY and ABRP_USER_TOKEN in the
// server's environment; the car never holds them. Forwarding is off when either is empty.
//
// Only the newest sample of a batch is sent, and at most one per MinInterval. A failed
// forward is logged and dropped, never queued: ABRP plans from the car's state now, and a
// backlog replayed later would describe a car that has moved on. The stored copy in
// InfluxDB is unaffected either way.
type Forwarder struct {
APIKey string
UserToken string
MinInterval time.Duration
Endpoint string
Client *http.Client
mu sync.Mutex
lastUTC int64
lastSentAt time.Time
}
const (
abrpEndpoint = "https://api.iternio.com/1/tlm/send"
// Older than this, a sample is history rather than live state, and ABRP gets nothing.
abrpMaxAge = 5 * time.Minute
)
func (f *Forwarder) configured() bool {
return f != nil && f.APIKey != "" && f.UserToken != ""
}
// pick chooses the sample to forward, or false when there is nothing new or it is too
// soon. It records the choice, so two batches arriving together cannot both forward.
func (f *Forwarder) pick(samples []TelemetrySample, now time.Time) (TelemetrySample, bool) {
if len(samples) == 0 {
return TelemetrySample{}, false
}
newest := samples[0]
for _, s := range samples[1:] {
if s.UTC > newest.UTC {
newest = s
}
}
if now.Sub(time.Unix(newest.UTC, 0)) > abrpMaxAge {
return TelemetrySample{}, false
}
f.mu.Lock()
defer f.mu.Unlock()
if f.lastUTC != 0 && newest.UTC <= f.lastUTC {
return TelemetrySample{}, false
}
if !f.lastSentAt.IsZero() && now.Sub(f.lastSentAt) < f.MinInterval {
return TelemetrySample{}, false
}
f.lastUTC = newest.UTC
f.lastSentAt = now
return newest, true
}
// Forward is fire-and-forget: the car's request is answered without waiting for ABRP.
func (f *Forwarder) Forward(samples []TelemetrySample) {
if !f.configured() {
return
}
sample, ok := f.pick(samples, time.Now())
if !ok {
return
}
go func() {
if err := f.send(context.Background(), sample); err != nil {
slog.Warn("ABRP forward failed", "error", err)
}
}()
}
// tlm is the object ABRP expects: the same field names, flags as 0/1.
func tlm(sample TelemetrySample) map[string]any {
out := map[string]any{"utc": sample.UTC}
for field, value := range sample.Numbers {
out[field] = value
}
for field, value := range sample.Flags {
if value {
out[field] = 1
} else {
out[field] = 0
}
}
return out
}
func (f *Forwarder) send(ctx context.Context, sample TelemetrySample) error {
payload, err := json.Marshal(tlm(sample))
if err != nil {
return err
}
// Credentials go in the form body, never the URL, which ends up in access logs.
form := url.Values{"api_key": {f.APIKey}, "token": {f.UserToken}, "tlm": {string(payload)}}
endpoint := f.Endpoint
if endpoint == "" {
endpoint = abrpEndpoint
}
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := f.Client
if client == nil {
client = http.DefaultClient
}
resp, err := client.Do(req)
if err != nil {
// A url.Error from the client quotes the URL, which holds no credentials.
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// The response body can echo the request; only the status is reported.
return fmt.Errorf("rejected: HTTP %d", resp.StatusCode)
}
return nil
}
+157
View File
@@ -0,0 +1,157 @@
package main
import (
"crypto/subtle"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strings"
)
// API serves the car. Every endpoint but /v1/health needs the ingest token.
//
// POST /v1/telemetry live samples; stored, newest forwarded to ABRP
// PUT /v1/consumption/daily one delta chunk of a day's consumption
// PUT /v1/charging-sessions/{sessionId} one finished DC charging session
// GET /v1/consumption/history/{installationId} the car reads its own totals back
// GET /v1/health unauthenticated liveness probe
type API struct {
Store Store
IngestToken string
Forwarder *Forwarder
}
const maxBodyBytes = 512 * 1024
func (a *API) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
mux.HandleFunc("POST /v1/telemetry", a.ingest(a.telemetry))
mux.HandleFunc("PUT /v1/consumption/daily", a.ingest(a.consumption))
mux.HandleFunc("PUT /v1/charging-sessions/{sessionId}", a.ingest(a.chargingSession))
mux.HandleFunc("GET /v1/consumption/history/{installationId}", a.ingest(a.history))
return mux
}
// hasIngestToken compares in constant time. main refuses to start with a token under 24
// characters, so an empty or short configured token can never match here.
func (a *API) hasIngestToken(r *http.Request) bool {
header := r.Header.Get("Authorization")
supplied, ok := strings.CutPrefix(header, "Bearer ")
if !ok || len(a.IngestToken) < minIngestTokenLength {
return false
}
return subtle.ConstantTimeCompare([]byte(supplied), []byte(a.IngestToken)) == 1
}
type handler func(w http.ResponseWriter, r *http.Request, body []byte) error
// ingest wraps a handler with the token check, the body limit and error mapping: a
// validation failure is a 400 naming the field; anything else is a 500 whose detail
// stays in the server log.
func (a *API) ingest(h handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !a.hasIngestToken(r) {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes+1))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body could not be read"})
return
}
if len(body) > maxBodyBytes {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body is too large"})
return
}
if err := h(w, r, body); err != nil {
var verr *ValidationError
if errors.As(err, &verr) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": verr.Error()})
return
}
slog.Error("request failed", "method", r.Method, "path", r.URL.Path, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
}
}
// telemetry stores the batch first; only once the write succeeded is the newest sample
// handed to ABRP, so the car's retry of a failed request cannot leave ABRP with data the
// database never got.
func (a *API) telemetry(w http.ResponseWriter, r *http.Request, body []byte) error {
batch, err := validateTelemetryBatch(body)
if err != nil {
return err
}
if err := a.Store.WriteTelemetry(r.Context(), batch.InstallationID, batch.Samples); err != nil {
return err
}
a.Forwarder.Forward(batch.Samples)
w.WriteHeader(http.StatusNoContent)
return nil
}
// consumption stores one delta chunk at its recordedAt. Reads add a day's chunks up, so a
// chunk the car re-sends from its offline cache replaces itself instead of counting twice.
func (a *API) consumption(w http.ResponseWriter, r *http.Request, body []byte) error {
chunk, err := validateConsumption(body)
if err != nil {
return err
}
if err := a.Store.WriteConsumptionChunk(r.Context(), chunk); err != nil {
return err
}
w.WriteHeader(http.StatusNoContent)
return nil
}
func (a *API) chargingSession(w http.ResponseWriter, r *http.Request, body []byte) error {
session, err := validateChargingSession(body, r.PathValue("sessionId"))
if err != nil {
return err
}
if err := a.Store.WriteChargingSession(r.Context(), session); err != nil {
return err
}
w.WriteHeader(http.StatusNoContent)
return nil
}
// history is the car's read-back of its own uploaded totals, so an app reinstall does not
// blank its Consumption tab. Up to 400 days, newest first, plus the lifetime sum of all.
func (a *API) history(w http.ResponseWriter, r *http.Request, _ []byte) error {
id, err := validUUID(r.PathValue("installationId"), "installationId")
if err != nil {
return err
}
chunks, err := a.Store.ReadConsumptionChunks(r.Context(), id)
if err != nil {
return err
}
days := aggregateDays(chunks)
var lifetime Totals
if len(days) > 0 {
lifetime = days[0].Lifetime
}
if len(days) > 400 {
days = days[:400]
}
writeJSON(w, http.StatusOK, struct {
Records []DailyConsumption `json:"records"`
Lifetime Totals `json:"lifetime"`
}{Records: days, Lifetime: lifetime})
return nil
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(value); err != nil {
slog.Warn("response not written", "error", err)
}
}
+170
View File
@@ -0,0 +1,170 @@
package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
type fakeStore struct {
mu sync.Mutex
telemetry []TelemetrySample
chunks []ConsumptionChunk
sessions []ChargingSession
fail error
}
func (f *fakeStore) WriteTelemetry(_ context.Context, _ string, s []TelemetrySample) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.fail != nil {
return f.fail
}
f.telemetry = append(f.telemetry, s...)
return nil
}
func (f *fakeStore) WriteConsumptionChunk(_ context.Context, c ConsumptionChunk) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.fail != nil {
return f.fail
}
f.chunks = append(f.chunks, c)
return nil
}
func (f *fakeStore) WriteChargingSession(_ context.Context, s ChargingSession) error {
f.mu.Lock()
defer f.mu.Unlock()
f.sessions = append(f.sessions, s)
return f.fail
}
func (f *fakeStore) ReadConsumptionChunks(context.Context, string) ([]ConsumptionChunk, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.chunks, f.fail
}
const testToken = "ingest-test-token-0123456789abcdef"
func newTestAPI() (*API, *fakeStore) {
store := &fakeStore{}
return &API{Store: store, IngestToken: testToken, Forwarder: &Forwarder{}}, store
}
func do(t *testing.T, h http.Handler, method, path, token, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestHealthNeedsNoToken(t *testing.T) {
api, _ := newTestAPI()
rec := do(t, api.Routes(), "GET", "/v1/health", "", "")
if rec.Code != 200 || strings.TrimSpace(rec.Body.String()) != `{"status":"ok"}` {
t.Fatalf("%d %s", rec.Code, rec.Body)
}
}
func TestEveryOtherEndpointNeedsTheIngestToken(t *testing.T) {
api, _ := newTestAPI()
h := api.Routes()
for _, c := range []struct{ method, path string }{
{"POST", "/v1/telemetry"},
{"PUT", "/v1/consumption/daily"},
{"PUT", "/v1/charging-sessions/" + testSession},
{"GET", "/v1/consumption/history/" + testInstallation},
} {
for _, token := range []string{"", "wrong-token-0123456789abcdef", testToken + "x"} {
if rec := do(t, h, c.method, c.path, token, "{}"); rec.Code != 401 {
t.Errorf("%s %s with %q: want 401, got %d", c.method, c.path, token, rec.Code)
}
}
}
}
func TestTelemetryIsStoredAndBadInputIsA400(t *testing.T) {
api, store := newTestAPI()
h := api.Routes()
rec := do(t, h, "POST", "/v1/telemetry", testToken, string(telemetryBody(`{"utc":1790000000,"soc":80}`)))
if rec.Code != 204 || len(store.telemetry) != 1 {
t.Fatalf("%d, stored %d", rec.Code, len(store.telemetry))
}
rec = do(t, h, "POST", "/v1/telemetry", testToken, string(telemetryBody(`{"utc":1790000000,"soc":120}`)))
if rec.Code != 400 || !strings.Contains(rec.Body.String(), "samples[0].soc is invalid") {
t.Fatalf("%d %s", rec.Code, rec.Body)
}
if rec := do(t, h, "GET", "/v1/telemetry", testToken, ""); rec.Code != 405 {
t.Fatalf("GET on a POST route: want 405, got %d", rec.Code)
}
}
func TestAFailedWriteIsA500WithoutDetail(t *testing.T) {
api, store := newTestAPI()
store.fail = errors.New("influx says: token xyz rejected")
rec := do(t, api.Routes(), "POST", "/v1/telemetry", testToken, string(telemetryBody(`{"utc":1790000000,"soc":80}`)))
if rec.Code != 500 || strings.Contains(rec.Body.String(), "xyz") {
t.Fatalf("%d %s", rec.Code, rec.Body)
}
}
func TestOversizedBodiesAreRejected(t *testing.T) {
api, _ := newTestAPI()
big := `{"x":"` + strings.Repeat("a", maxBodyBytes) + `"}`
if rec := do(t, api.Routes(), "PUT", "/v1/consumption/daily", testToken, big); rec.Code != 400 {
t.Fatalf("want 400, got %d", rec.Code)
}
}
func TestHistoryReturnsDaysNewestFirstAndTheLifetime(t *testing.T) {
api, _ := newTestAPI()
h := api.Routes()
day := func(date string, at int64) string {
return `{"installationId":"` + testInstallation + `","date":"` + date + `","recordedAt":` +
jsonInt(at) + `,"day":{"km":2.5,"kwh":0.4,"hours":0.03,"socDrop":0.6}}`
}
// Two chunks on one day and one on the next. Retry dedupe is InfluxDB's overwrite and is
// covered by the end-to-end check, not this fake.
for _, body := range []string{day("2026-10-16", 1000), day("2026-10-16", 2000), day("2026-10-17", 3000)} {
if rec := do(t, h, "PUT", "/v1/consumption/daily", testToken, body); rec.Code != 204 {
t.Fatalf("chunk: %d %s", rec.Code, rec.Body)
}
}
rec := do(t, h, "GET", "/v1/consumption/history/"+testInstallation, testToken, "")
if rec.Code != 200 {
t.Fatalf("%d %s", rec.Code, rec.Body)
}
var got struct {
Records []struct {
Date string `json:"date"`
Day Totals `json:"day"`
} `json:"records"`
Lifetime Totals `json:"lifetime"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if len(got.Records) != 2 || got.Records[0].Date != "2026-10-17" || got.Records[1].Day.Km != 5 || got.Lifetime.Km != 7.5 {
t.Fatalf("%s", rec.Body)
}
if rec := do(t, h, "GET", "/v1/consumption/history/not-a-uuid", testToken, ""); rec.Code != 400 {
t.Fatalf("bad id: want 400, got %d", rec.Code)
}
}
func jsonInt(v int64) string {
b, _ := json.Marshal(v)
return string(b)
}
+51
View File
@@ -0,0 +1,51 @@
package main
import "sort"
// aggregateDays folds chunks into days, newest day first, each with its running lifetime
// total. Pure, so the arithmetic is tested without a database.
func aggregateDays(chunks []ConsumptionChunk) []DailyConsumption {
type day struct {
DailyConsumption
sohAt int64
hasSoh bool
}
byDate := map[string]*day{}
for _, chunk := range chunks {
d, ok := byDate[chunk.Date]
if !ok {
d = &day{DailyConsumption: DailyConsumption{
InstallationID: chunk.InstallationID,
Date: chunk.Date,
RecordedAt: chunk.RecordedAt,
}}
byDate[chunk.Date] = d
}
d.Day.add(chunk.Day)
if chunk.RecordedAt.After(d.RecordedAt) {
d.RecordedAt = chunk.RecordedAt
}
// The newest manual SoH of the day wins.
at := chunk.RecordedAt.UnixMilli()
if chunk.SohPercent != nil && (!d.hasSoh || at > d.sohAt) {
soh := *chunk.SohPercent
d.SohPercent = &soh
d.sohAt = at
d.hasSoh = true
}
}
days := make([]DailyConsumption, 0, len(byDate))
for _, d := range byDate {
days = append(days, d.DailyConsumption)
}
sort.Slice(days, func(i, j int) bool { return days[i].Date < days[j].Date })
var running Totals
for i := range days {
running.add(days[i].Day)
days[i].Lifetime = running
}
for i, j := 0, len(days)-1; i < j; i, j = i+1, j-1 {
days[i], days[j] = days[j], days[i]
}
return days
}
+15
View File
@@ -0,0 +1,15 @@
module git.stinnesbeck.com/nils/mg4/server
go 1.26.0
toolchain go1.26.9
require github.com/influxdata/influxdb-client-go/v2 v2.14.0
require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/google/uuid v1.3.1 // indirect
github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect
github.com/oapi-codegen/runtime v1.0.0 // indirect
golang.org/x/net v0.60.0 // indirect
)
+27
View File
@@ -0,0 +1,27 @@
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/influxdata/influxdb-client-go/v2 v2.14.0 h1:AjbBfJuq+QoaXNcrova8smSjwJdUHnwvfjMF71M1iI4=
github.com/influxdata/influxdb-client-go/v2 v2.14.0/go.mod h1:Ahpm3QXKMJslpXl3IftVLVezreAUtBOTZssDrjZEFHI=
github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7wlPfJLvMCdtV4zPulc4uCPrlywQOmbFOhgQNU=
github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/oapi-codegen/runtime v1.0.0 h1:P4rqFX5fMFWqRzY9M/3YF9+aPSPPB06IzP2P7oOxrWo=
github.com/oapi-codegen/runtime v1.0.0/go.mod h1:LmCUMQuPB4M/nLXilQXhHw+BLZdDb18B34OO356yJ/A=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+121
View File
@@ -0,0 +1,121 @@
package main
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
)
func chunk(date string, atMs int64, km, kwh float64, soh *float64) ConsumptionChunk {
return ConsumptionChunk{
InstallationID: testInstallation, Date: date, RecordedAt: time.UnixMilli(atMs),
Day: Totals{Km: km, Kwh: kwh, Hours: km / 50, SocDrop: kwh / 0.6}, SohPercent: soh,
}
}
func ptr(f float64) *float64 { return &f }
func TestAggregateDaysSumsPerDayNewestFirstWithRunningLifetime(t *testing.T) {
days := aggregateDays([]ConsumptionChunk{
chunk("2026-10-16", 1000, 10, 2, nil),
chunk("2026-10-17", 3000, 5, 1, nil),
chunk("2026-10-16", 2000, 20, 3, nil),
})
if len(days) != 2 || days[0].Date != "2026-10-17" || days[1].Date != "2026-10-16" {
t.Fatalf("order: %+v", days)
}
if days[1].Day.Km != 30 || days[1].Day.Kwh != 5 || days[1].RecordedAt.UnixMilli() != 2000 {
t.Fatalf("day sums: %+v", days[1])
}
if days[1].Lifetime.Km != 30 || days[0].Lifetime.Km != 35 || days[0].Lifetime.Kwh != 6 {
t.Fatalf("lifetime: %+v / %+v", days[0].Lifetime, days[1].Lifetime)
}
}
func TestAggregateDaysNewestSohWins(t *testing.T) {
days := aggregateDays([]ConsumptionChunk{
chunk("2026-10-16", 2000, 1, 1, ptr(96)),
chunk("2026-10-16", 1000, 1, 1, ptr(98)),
chunk("2026-10-17", 3000, 1, 1, nil),
})
if *days[1].SohPercent != 96 {
t.Fatalf("want 96, got %v", *days[1].SohPercent)
}
if days[0].SohPercent != nil {
t.Fatal("a day without SoH got one")
}
}
func TestTlmSendsFlagsAsZeroOrOne(t *testing.T) {
got := tlm(TelemetrySample{UTC: testNowS, Numbers: map[string]float64{"soc": 50},
Flags: map[string]bool{"is_charging": true, "is_parked": false}})
if got["utc"] != int64(testNowS) || got["soc"] != 50.0 || got["is_charging"] != 1 || got["is_parked"] != 0 {
t.Fatalf("tlm %+v", got)
}
}
func TestForwarderPicksOnlyTheNewestFreshSampleOncePerInterval(t *testing.T) {
f := &Forwarder{MinInterval: 5 * time.Second}
now := time.Unix(testNowS, 0)
got, ok := f.pick([]TelemetrySample{{UTC: testNowS - 10}, {UTC: testNowS - 2}, {UTC: testNowS - 5}}, now)
if !ok || got.UTC != testNowS-2 {
t.Fatalf("picked %+v %v", got, ok)
}
if _, ok := f.pick([]TelemetrySample{{UTC: testNowS}}, now.Add(time.Second)); ok {
t.Fatal("forwarded again within the interval")
}
if _, ok := f.pick([]TelemetrySample{{UTC: testNowS - 3}}, now.Add(time.Minute)); ok {
t.Fatal("forwarded an older sample than the last one")
}
// A sample from ten minutes ago describes a car that has moved on.
fresh := &Forwarder{MinInterval: 5 * time.Second}
if _, ok := fresh.pick([]TelemetrySample{{UTC: testNowS - 600}}, now); ok {
t.Fatal("forwarded a stale sample")
}
}
func TestForwarderPutsCredentialsInTheBodyNotTheURL(t *testing.T) {
received := make(chan *http.Request, 1)
var form url.Values
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
form, _ = url.ParseQuery(string(body))
received <- r
}))
defer srv.Close()
f := &Forwarder{APIKey: "key-123", UserToken: "token-456", Endpoint: srv.URL + "/1/tlm/send"}
if err := f.send(t.Context(), TelemetrySample{UTC: testNowS, Numbers: map[string]float64{"soc": 80}}); err != nil {
t.Fatal(err)
}
r := <-received
if r.URL.RawQuery != "" {
t.Fatalf("credentials leaked into the URL: %s", r.URL.RawQuery)
}
if form.Get("api_key") != "key-123" || form.Get("token") != "token-456" || form.Get("tlm") != `{"soc":80,"utc":1790000000}` {
t.Fatalf("form %v", form)
}
}
func TestLoadConfigFailsClosed(t *testing.T) {
env := map[string]string{
"INFLUX_URL": "http://influxdb:8086", "INFLUX_TOKEN": "t", "INFLUX_ORG": "mg4",
"INGEST_TOKEN": "0123456789abcdef01234567",
}
get := func(k string) string { return env[k] }
c, err := loadConfig(get)
if err != nil || c.listenAddr != ":8080" || c.influxBucket != "mg4" || c.abrpMinInterval != 5*time.Second {
t.Fatalf("defaults: %+v %v", c, err)
}
env["INGEST_TOKEN"] = "short"
if _, err := loadConfig(get); err == nil {
t.Fatal("short ingest token accepted")
}
env["INGEST_TOKEN"] = "0123456789abcdef01234567"
delete(env, "INFLUX_TOKEN")
if _, err := loadConfig(get); err == nil {
t.Fatal("missing InfluxDB token accepted")
}
}
+113
View File
@@ -0,0 +1,113 @@
// Command server is the Nils' MG4 car server: the API the car writes its telemetry,
// consumption and charging sessions to, stored in InfluxDB 2 and forwarded live to ABRP.
// Dashboards are Grafana's job; Grafana reads the same bucket directly.
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
)
const minIngestTokenLength = 24
type config struct {
listenAddr string
influxURL string
influxToken string
influxOrg string
influxBucket string
ingestToken string
abrpAPIKey string
abrpUserToken string
abrpMinInterval time.Duration
}
// loadConfig reads the environment and fails closed: a missing InfluxDB setting or a
// short ingest token stops the server from starting rather than serving something broken.
func loadConfig(getenv func(string) string) (config, error) {
c := config{
listenAddr: getenv("LISTEN_ADDR"),
influxURL: getenv("INFLUX_URL"),
influxToken: getenv("INFLUX_TOKEN"),
influxOrg: getenv("INFLUX_ORG"),
influxBucket: getenv("INFLUX_BUCKET"),
ingestToken: getenv("INGEST_TOKEN"),
abrpAPIKey: getenv("ABRP_API_KEY"),
abrpUserToken: getenv("ABRP_USER_TOKEN"),
}
if c.listenAddr == "" {
c.listenAddr = ":8080"
}
if c.influxBucket == "" {
c.influxBucket = "mg4"
}
for name, value := range map[string]string{"INFLUX_URL": c.influxURL, "INFLUX_TOKEN": c.influxToken, "INFLUX_ORG": c.influxOrg} {
if value == "" {
return config{}, fmt.Errorf("%s is not configured", name)
}
}
if len(c.ingestToken) < minIngestTokenLength {
return config{}, fmt.Errorf("INGEST_TOKEN must be at least %d characters", minIngestTokenLength)
}
c.abrpMinInterval = 5 * time.Second
if raw := getenv("ABRP_MIN_INTERVAL_SECONDS"); raw != "" {
seconds, err := strconv.ParseFloat(raw, 64)
if err != nil || seconds < 0 {
return config{}, errors.New("ABRP_MIN_INTERVAL_SECONDS must be a non-negative number")
}
c.abrpMinInterval = time.Duration(seconds * float64(time.Second))
}
return c, nil
}
func main() {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil)))
cfg, err := loadConfig(os.Getenv)
if err != nil {
slog.Error("invalid configuration", "error", err)
os.Exit(1)
}
store := NewInfluxStore(cfg.influxURL, cfg.influxToken, cfg.influxOrg, cfg.influxBucket)
defer store.Close()
forwarder := &Forwarder{
APIKey: cfg.abrpAPIKey,
UserToken: cfg.abrpUserToken,
MinInterval: cfg.abrpMinInterval,
Client: &http.Client{Timeout: 10 * time.Second},
}
api := &API{Store: store, IngestToken: cfg.ingestToken, Forwarder: forwarder}
srv := &http.Server{
Addr: cfg.listenAddr,
Handler: api.Routes(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
slog.Info("listening", "addr", cfg.listenAddr, "bucket", cfg.influxBucket, "abrp", forwarder.configured())
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("server failed", "error", err)
os.Exit(1)
}
}()
<-ctx.Done()
shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdown); err != nil {
slog.Error("shutdown", "error", err)
}
}
+108
View File
@@ -0,0 +1,108 @@
package main
import "time"
// Totals are one day's (or one chunk's) consumption counters.
type Totals struct {
Km float64 `json:"km"`
Kwh float64 `json:"kwh"`
Hours float64 `json:"hours"`
SocDrop float64 `json:"socDrop"`
}
func (t *Totals) add(o Totals) {
t.Km += o.Km
t.Kwh += o.Kwh
t.Hours += o.Hours
t.SocDrop += o.SocDrop
}
// ConsumptionChunk is one 2-minute delta of a day's consumption, as the car sends it.
// Days are built from these when history is read.
type ConsumptionChunk struct {
InstallationID string
Date string // the car's local date, YYYY-MM-DD
RecordedAt time.Time
Day Totals
SohPercent *float64
}
// DailyConsumption is a day folded from its chunks.
type DailyConsumption struct {
InstallationID string `json:"installationId"`
Date string `json:"date"`
RecordedAt time.Time `json:"recordedAt"`
Day Totals `json:"day"`
SohPercent *float64 `json:"sohPercent,omitempty"`
// Lifetime is the running total up to and including this day; not part of the
// car's history response, which carries one lifetime for all days.
Lifetime Totals `json:"-"`
}
type CurvePoint struct {
Timestamp time.Time
PowerKw float64
SocPercent *float64
}
type ChargingSession struct {
InstallationID string
SessionID string
StartedAt time.Time
EndedAt time.Time
DurationSeconds float64
EnergyKwh float64
ChargingLossPercent float64
GridEnergyKwh float64
PricePerKwh float64
TotalCost float64
IsDemo bool
StartSocPercent *float64
EndSocPercent *float64
Curve []CurvePoint
}
// telemetryNumbers are the numeric live fields, named and unit-ed as ABRP's tlm/send
// expects them, with the range each one must fall in.
var telemetryNumbers = map[string][2]float64{
"soc": {0, 100}, // %
"speed": {0, 400}, // km/h
"power": {-1000, 1000}, // kW, positive leaving the battery
"est_battery_range": {0, 2000}, // km
"capacity": {0, 500}, // kWh
"soe": {0, 500}, // kWh
"soh": {0, 100}, // %
"kwh_charged": {0, 1000}, // kWh, this charge session
"odometer": {0, 10_000_000}, // km
"lat": {-90, 90}, // °
"lon": {-180, 180}, // °
"elevation": {-1000, 10_000}, // m
"heading": {0, 360}, // °
"batt_temp": {-60, 100}, // °C
"cabin_temp": {-60, 100}, // °C
"ext_temp": {-60, 70}, // °C
"hvac_setpoint": {0, 40}, // °C
"hvac_power": {0, 50}, // kW
"voltage": {0, 1500}, // V
"current": {-2000, 2000}, // A
"tire_pressure_fl": {0, 1000}, // kPa
"tire_pressure_fr": {0, 1000}, // kPa
"tire_pressure_rl": {0, 1000}, // kPa
"tire_pressure_rr": {0, 1000}, // kPa
}
// telemetryFlags are the boolean live fields; ABRP takes them as 0/1.
var telemetryFlags = []string{"is_charging", "is_dcfc", "is_parked"}
// TelemetrySample is one live sample. Only UTC is always present; an unread signal is
// absent from Numbers and Flags, never 0.
type TelemetrySample struct {
UTC int64 // epoch seconds
Numbers map[string]float64
Flags map[string]bool
}
type TelemetryBatch struct {
InstallationID string
Samples []TelemetrySample
}
+162
View File
@@ -0,0 +1,162 @@
package main
import (
"context"
"fmt"
"strings"
"time"
influxdb2 "github.com/influxdata/influxdb-client-go/v2"
"github.com/influxdata/influxdb-client-go/v2/api"
"github.com/influxdata/influxdb-client-go/v2/api/write"
)
// Store is what the handlers need from the database; a fake implements it in tests.
type Store interface {
WriteTelemetry(ctx context.Context, installationID string, samples []TelemetrySample) error
WriteConsumptionChunk(ctx context.Context, chunk ConsumptionChunk) error
WriteChargingSession(ctx context.Context, session ChargingSession) error
ReadConsumptionChunks(ctx context.Context, installationID string) ([]ConsumptionChunk, error)
}
// InfluxStore keeps everything in one InfluxDB 2 bucket; every point carries an
// installationId tag. Grafana reads the same bucket directly.
//
// telemetry time = sample utc ABRP tlm fields (floats and booleans)
// consumption_chunk time = recordedAt tags: date km, kwh, hours, socDrop, sohPercent
// charging_session time = startedAt tags: sessionId summary fields
// charging_curve time = point time tags: sessionId powerKw, socPercent
//
// Idempotency comes from InfluxDB itself: a point with the same measurement, tags and
// timestamp replaces the earlier one. The car re-sends a cached chunk with its original
// recordedAt and a session under its own id, so a retry overwrites instead of adding.
//
// Every number is written as a float. InfluxDB fixes a field's type on first write, so
// a 0 written as an integer would make every later 0.5 a rejected write.
type InfluxStore struct {
client influxdb2.Client
write api.WriteAPIBlocking
query api.QueryAPI
bucket string
}
const tagInstallation = "installationId"
func NewInfluxStore(url, token, org, bucket string) *InfluxStore {
client := influxdb2.NewClientWithOptions(url, token,
influxdb2.DefaultOptions().SetHTTPRequestTimeout(10).SetPrecision(time.Millisecond))
return &InfluxStore{
client: client,
write: client.WriteAPIBlocking(org, bucket),
query: client.QueryAPI(org),
bucket: bucket,
}
}
func (s *InfluxStore) Close() { s.client.Close() }
func (s *InfluxStore) WriteTelemetry(ctx context.Context, id string, samples []TelemetrySample) error {
points := make([]*write.Point, 0, len(samples))
for _, sample := range samples {
fields := make(map[string]any, len(sample.Numbers)+len(sample.Flags))
for k, v := range sample.Numbers {
fields[k] = v
}
for k, v := range sample.Flags {
fields[k] = v
}
if len(fields) == 0 {
// A point needs at least one field; a bare timestamp says nothing anyway.
continue
}
points = append(points, influxdb2.NewPoint("telemetry",
map[string]string{tagInstallation: id}, fields, time.Unix(sample.UTC, 0)))
}
if len(points) == 0 {
return nil
}
return s.write.WritePoint(ctx, points...)
}
func (s *InfluxStore) WriteConsumptionChunk(ctx context.Context, c ConsumptionChunk) error {
fields := map[string]any{"km": c.Day.Km, "kwh": c.Day.Kwh, "hours": c.Day.Hours, "socDrop": c.Day.SocDrop}
if c.SohPercent != nil {
fields["sohPercent"] = *c.SohPercent
}
return s.write.WritePoint(ctx, influxdb2.NewPoint("consumption_chunk",
map[string]string{tagInstallation: c.InstallationID, "date": c.Date}, fields, c.RecordedAt))
}
func (s *InfluxStore) WriteChargingSession(ctx context.Context, cs ChargingSession) error {
tags := map[string]string{tagInstallation: cs.InstallationID, "sessionId": cs.SessionID}
summary := map[string]any{
"endedAtMs": cs.EndedAt.UnixMilli(),
"durationSeconds": cs.DurationSeconds,
"energyKwh": cs.EnergyKwh,
"chargingLossPercent": cs.ChargingLossPercent,
"gridEnergyKwh": cs.GridEnergyKwh,
"pricePerKwh": cs.PricePerKwh,
"totalCost": cs.TotalCost,
"isDemo": cs.IsDemo,
}
if cs.StartSocPercent != nil {
summary["startSocPercent"] = *cs.StartSocPercent
}
if cs.EndSocPercent != nil {
summary["endSocPercent"] = *cs.EndSocPercent
}
points := []*write.Point{influxdb2.NewPoint("charging_session", tags, summary, cs.StartedAt)}
for _, p := range cs.Curve {
fields := map[string]any{"powerKw": p.PowerKw}
if p.SocPercent != nil {
fields["socPercent"] = *p.SocPercent
}
points = append(points, influxdb2.NewPoint("charging_curve", tags, fields, p.Timestamp))
}
return s.write.WritePoint(ctx, points...)
}
// fluxString quotes a value as a Flux string literal. Ids are validated UUIDs before they
// get here; the escaping is for the bucket name and as a second line of defence.
func fluxString(value string) string {
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`, "$", `\$`).Replace(value) + `"`
}
func (s *InfluxStore) ReadConsumptionChunks(ctx context.Context, id string) ([]ConsumptionChunk, error) {
query := fmt.Sprintf(`from(bucket: %s)
|> range(start: 0)
|> filter(fn: (r) => r._measurement == "consumption_chunk" and r.installationId == %s)
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")`,
fluxString(s.bucket), fluxString(id))
result, err := s.query.Query(ctx, query)
if err != nil {
return nil, err
}
defer result.Close()
var chunks []ConsumptionChunk
for result.Next() {
r := result.Record()
date, _ := r.ValueByKey("date").(string)
chunk := ConsumptionChunk{
InstallationID: id,
Date: date,
RecordedAt: r.Time(),
Day: Totals{
Km: floatValue(r.ValueByKey("km")),
Kwh: floatValue(r.ValueByKey("kwh")),
Hours: floatValue(r.ValueByKey("hours")),
SocDrop: floatValue(r.ValueByKey("socDrop")),
},
}
if soh, ok := r.ValueByKey("sohPercent").(float64); ok {
chunk.SohPercent = &soh
}
chunks = append(chunks, chunk)
}
return chunks, result.Err()
}
func floatValue(v any) float64 {
f, _ := v.(float64)
return f
}
+327
View File
@@ -0,0 +1,327 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"math"
"regexp"
"time"
)
// Request validation for the ingest endpoints, ported rule for rule from the TypeScript
// server. The car is the only writer and it is trusted, but a bad field is written once
// and read forever: every number is bounded, and an out-of-range value is rejected rather
// than stored and quietly plotted.
// ValidationError is a 400: the message may name the bad field.
type ValidationError struct{ msg string }
func (e *ValidationError) Error() string { return e.msg }
func invalid(format string, args ...any) error {
return &ValidationError{msg: fmt.Sprintf(format, args...)}
}
var (
uuidPattern = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
isoDatePattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
)
const (
maxSamples = 500
maxCurvePoints = 720
maxSafeInteger = 1<<53 - 1
utcMin = 1_000_000_000 // epoch seconds: 2001…
utcMax = 9_999_999_999 // …2286; milliseconds would be far above
totalsMaxAbs = 10_000_000
chargingLossPct = 10
)
// object is a decoded JSON object whose numbers stay json.Number, so integers and
// floats can be told apart and nothing is rounded on the way in.
type object map[string]any
func decodeObject(body []byte) (object, error) {
dec := json.NewDecoder(bytes.NewReader(body))
dec.UseNumber()
var value any
if err := dec.Decode(&value); err != nil {
return nil, invalid("body is not valid JSON")
}
if dec.More() {
return nil, invalid("body is not valid JSON")
}
return asObject(value, "body")
}
func asObject(value any, name string) (object, error) {
o, ok := value.(map[string]any)
if !ok {
if name == "body" {
return nil, invalid("body is required")
}
return nil, invalid("%s is invalid", name)
}
return object(o), nil
}
func (o object) has(key string) bool {
v, ok := o[key]
return ok && v != nil
}
func number(value any, name string) (float64, error) {
n, ok := value.(json.Number)
if !ok {
return 0, invalid("%s is invalid", name)
}
f, err := n.Float64()
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
return 0, invalid("%s is invalid", name)
}
return f, nil
}
func bounded(value any, min, max float64, name string) (float64, error) {
f, err := number(value, name)
if err != nil || f < min || f > max {
return 0, invalid("%s is invalid", name)
}
return f, nil
}
// integer accepts a whole, non-negative number up to 2^53-1 — what JavaScript and the
// car's JSON can carry exactly.
func integer(value any, name string) (int64, error) {
f, err := number(value, name)
if err != nil || f != math.Trunc(f) || f < 0 || f > maxSafeInteger {
return 0, invalid("%s is invalid", name)
}
return int64(f), nil
}
func validDate(value any, name string) (time.Time, error) {
ms, err := integer(value, name)
if err != nil {
return time.Time{}, err
}
return time.UnixMilli(ms).UTC(), nil
}
func validUUID(value any, name string) (string, error) {
s, ok := value.(string)
if !ok || !uuidPattern.MatchString(s) {
return "", invalid("%s is invalid", name)
}
return s, nil
}
func optionalBounded(o object, key string, min, max float64, name string) (*float64, error) {
if !o.has(key) {
return nil, nil
}
f, err := bounded(o[key], min, max, name)
if err != nil {
return nil, err
}
return &f, nil
}
func validTotals(value any, name string) (Totals, error) {
o, err := asObject(value, name)
if err != nil {
return Totals{}, err
}
var t Totals
for _, field := range []struct {
key string
dst *float64
}{{"km", &t.Km}, {"kwh", &t.Kwh}, {"hours", &t.Hours}, {"socDrop", &t.SocDrop}} {
f, err := number(o[field.key], name+"."+field.key)
if err != nil || math.Abs(f) > totalsMaxAbs {
return Totals{}, invalid("%s.%s is invalid", name, field.key)
}
*field.dst = f
}
return t, nil
}
func validateConsumption(body []byte) (ConsumptionChunk, error) {
o, err := decodeObject(body)
if err != nil {
return ConsumptionChunk{}, err
}
date, ok := o["date"].(string)
if !ok || !isoDatePattern.MatchString(date) {
return ConsumptionChunk{}, invalid("date is invalid")
}
id, err := validUUID(o["installationId"], "installationId")
if err != nil {
return ConsumptionChunk{}, err
}
recordedAt, err := validDate(o["recordedAt"], "recordedAt")
if err != nil {
return ConsumptionChunk{}, err
}
day, err := validTotals(o["day"], "day")
if err != nil {
return ConsumptionChunk{}, err
}
// A lifetime from an older app is still validated, so a malformed one is still a 400,
// but it is not stored: lifetime is derived from the day totals when read.
if o.has("lifetime") {
if _, err := validTotals(o["lifetime"], "lifetime"); err != nil {
return ConsumptionChunk{}, err
}
}
soh, err := optionalBounded(o, "sohPercent", 0, 100, "sohPercent")
if err != nil {
return ConsumptionChunk{}, err
}
return ConsumptionChunk{InstallationID: id, Date: date, RecordedAt: recordedAt, Day: day, SohPercent: soh}, nil
}
func validateChargingSession(body []byte, pathSessionID string) (ChargingSession, error) {
o, err := decodeObject(body)
if err != nil {
return ChargingSession{}, err
}
sessionID, err := validUUID(o["sessionId"], "sessionId")
if err != nil {
return ChargingSession{}, err
}
if sessionID != pathSessionID {
return ChargingSession{}, invalid("sessionId does not match path")
}
rawCurve, ok := o["curve"].([]any)
if !ok || len(rawCurve) > maxCurvePoints {
return ChargingSession{}, invalid("curve is invalid")
}
curve := make([]CurvePoint, 0, len(rawCurve))
for _, entry := range rawCurve {
p, err := asObject(entry, "curve")
if err != nil {
return ChargingSession{}, err
}
ts, err := validDate(p["timestampMs"], "curve.timestampMs")
if err != nil {
return ChargingSession{}, err
}
power, err := bounded(p["powerKw"], 0, 1000, "curve.powerKw")
if err != nil {
return ChargingSession{}, err
}
soc, err := optionalBounded(p, "socPercent", 0, 100, "curve.socPercent")
if err != nil {
return ChargingSession{}, err
}
curve = append(curve, CurvePoint{Timestamp: ts, PowerKw: power, SocPercent: soc})
}
s := ChargingSession{SessionID: sessionID, Curve: curve, ChargingLossPercent: chargingLossPct}
if s.EnergyKwh, err = bounded(o["energyKwh"], 0, 1000, "energyKwh"); err != nil {
return ChargingSession{}, err
}
if s.PricePerKwh, err = bounded(o["pricePerKwh"], 0, 1_000_000, "pricePerKwh"); err != nil {
return ChargingSession{}, err
}
if s.InstallationID, err = validUUID(o["installationId"], "installationId"); err != nil {
return ChargingSession{}, err
}
if s.StartedAt, err = validDate(o["startedAt"], "startedAt"); err != nil {
return ChargingSession{}, err
}
if s.EndedAt, err = validDate(o["endedAt"], "endedAt"); err != nil {
return ChargingSession{}, err
}
if s.DurationSeconds, err = bounded(o["durationSeconds"], 0, 604800, "durationSeconds"); err != nil {
return ChargingSession{}, err
}
if s.StartSocPercent, err = optionalBounded(o, "startSocPercent", 0, 100, "startSocPercent"); err != nil {
return ChargingSession{}, err
}
if s.EndSocPercent, err = optionalBounded(o, "endSocPercent", 0, 100, "endSocPercent"); err != nil {
return ChargingSession{}, err
}
// The car's own loss and grid figures are not trusted: the server applies its fixed 10 %.
s.GridEnergyKwh = s.EnergyKwh * 1.1
s.TotalCost = s.GridEnergyKwh * s.PricePerKwh
s.IsDemo = o["isDemo"] == true
return s, nil
}
// validateTelemetryBatch checks a batch of live samples in ABRP's tlm field names and
// units. Unknown fields are dropped rather than rejected, so a newer app can add one
// without a server release; every known field is bounded; a flag may be 0/1 or a boolean.
func validateTelemetryBatch(body []byte) (TelemetryBatch, error) {
o, err := decodeObject(body)
if err != nil {
return TelemetryBatch{}, err
}
id, err := validUUID(o["installationId"], "installationId")
if err != nil {
return TelemetryBatch{}, err
}
raw, ok := o["samples"].([]any)
if !ok || len(raw) == 0 || len(raw) > maxSamples {
return TelemetryBatch{}, invalid("samples is invalid")
}
samples := make([]TelemetrySample, 0, len(raw))
for i, entry := range raw {
sample, err := validateSample(entry, fmt.Sprintf("samples[%d]", i))
if err != nil {
return TelemetryBatch{}, err
}
samples = append(samples, sample)
}
return TelemetryBatch{InstallationID: id, Samples: samples}, nil
}
func validateSample(entry any, name string) (TelemetrySample, error) {
o, err := asObject(entry, name)
if err != nil {
return TelemetrySample{}, err
}
utc, err := integer(o["utc"], name+".utc")
if err != nil || utc < utcMin || utc > utcMax {
return TelemetrySample{}, invalid("%s.utc is invalid", name)
}
sample := TelemetrySample{UTC: utc, Numbers: map[string]float64{}, Flags: map[string]bool{}}
for field, limits := range telemetryNumbers {
if !o.has(field) {
continue
}
f, err := bounded(o[field], limits[0], limits[1], name+"."+field)
if err != nil {
return TelemetrySample{}, err
}
sample.Numbers[field] = f
}
for _, field := range telemetryFlags {
if !o.has(field) {
continue
}
switch v := o[field].(type) {
case bool:
sample.Flags[field] = v
case json.Number:
switch v.String() {
case "1":
sample.Flags[field] = true
case "0":
sample.Flags[field] = false
default:
return TelemetrySample{}, invalid("%s.%s is invalid", name, field)
}
default:
return TelemetrySample{}, invalid("%s.%s is invalid", name, field)
}
}
// A position is only a position as a pair.
_, hasLat := sample.Numbers["lat"]
_, hasLon := sample.Numbers["lon"]
if hasLat != hasLon {
return TelemetrySample{}, invalid("%s.lat and lon must be sent together", name)
}
return sample, nil
}
+185
View File
@@ -0,0 +1,185 @@
package main
import (
"errors"
"strings"
"testing"
)
const (
testInstallation = "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b"
testSession = "11111111-2222-4333-8444-555555555555"
testNowS = 1_790_000_000
)
func isValidationError(err error) bool {
var v *ValidationError
return errors.As(err, &v)
}
func TestConsumptionAcceptsAWellFormedChunk(t *testing.T) {
c, err := validateConsumption([]byte(`{"installationId":"` + testInstallation + `","date":"2026-10-07",
"recordedAt":1760000000000,"day":{"km":42.5,"kwh":7.25,"hours":0.9,"socDrop":11.5},
"lifetime":{"km":12000,"kwh":2100,"hours":240,"socDrop":3400},"sohPercent":97.5}`))
if err != nil {
t.Fatal(err)
}
if c.Date != "2026-10-07" || c.Day.Kwh != 7.25 || c.RecordedAt.UnixMilli() != 1760000000000 || *c.SohPercent != 97.5 {
t.Fatalf("unexpected chunk %+v", c)
}
}
func TestConsumptionMissingSohIsOmittedNotDefaulted(t *testing.T) {
// The car omits a field it could not read. Writing 0 would be a battery at nothing.
c, err := validateConsumption([]byte(`{"installationId":"` + testInstallation + `","date":"2026-10-07",
"recordedAt":1,"day":{"km":1,"kwh":1,"hours":1,"socDrop":1}}`))
if err != nil {
t.Fatal(err)
}
if c.SohPercent != nil {
t.Fatalf("SoH should be absent, got %v", *c.SohPercent)
}
}
func TestConsumptionRejectsBadInput(t *testing.T) {
base := map[string]string{
"installationId": `"` + testInstallation + `"`, "date": `"2026-10-07"`, "recordedAt": "1",
"day": `{"km":1,"kwh":1,"hours":1,"socDrop":1}`,
}
cases := map[string][2]string{
"bad id": {"installationId", `"nope"`},
"german date": {"date", `"07.10.2026"`},
"negative timestamp": {"recordedAt", "-5"},
"fractional time": {"recordedAt", "1.5"},
"string number": {"day", `{"km":"1","kwh":1,"hours":1,"socDrop":1}`},
"huge totals": {"day", `{"km":1e9,"kwh":1,"hours":1,"socDrop":1}`},
"missing field": {"day", `{"km":1,"kwh":1,"hours":1}`},
"bad lifetime": {"lifetime", `{"km":1}`},
"soh above 100": {"sohPercent", "101"},
}
for name, change := range cases {
fields := map[string]string{}
for k, v := range base {
fields[k] = v
}
fields[change[0]] = change[1]
var parts []string
for k, v := range fields {
parts = append(parts, `"`+k+`":`+v)
}
if _, err := validateConsumption([]byte("{" + strings.Join(parts, ",") + "}")); !isValidationError(err) {
t.Errorf("%s: want ValidationError, got %v", name, err)
}
}
for _, body := range []string{"", "[]", "null", "{", `{"a":1}{"b":2}`} {
if _, err := validateConsumption([]byte(body)); !isValidationError(err) {
t.Errorf("body %q: want ValidationError, got %v", body, err)
}
}
}
func chargingBody(overrides string) []byte {
return []byte(`{"installationId":"` + testInstallation + `","sessionId":"` + testSession + `",
"startedAt":1760500000000,"endedAt":1760501800000,"durationSeconds":1800,"energyKwh":30,
"pricePerKwh":0.59,"startSocPercent":20,"endSocPercent":80,
"curve":[{"timestampMs":1760500060000,"powerKw":95,"socPercent":21},{"timestampMs":1760500120000,"powerKw":110}]` +
overrides + `}`)
}
func TestChargingSessionAppliesTheServersLossAndCost(t *testing.T) {
// The car's own grid and cost figures are not trusted; the server applies 10 % loss.
s, err := validateChargingSession(chargingBody(`,"gridEnergyKwh":1,"totalCost":1`), testSession)
if err != nil {
t.Fatal(err)
}
if s.GridEnergyKwh != 33 || s.TotalCost != 33*0.59 || s.ChargingLossPercent != 10 {
t.Fatalf("grid %v cost %v loss %v", s.GridEnergyKwh, s.TotalCost, s.ChargingLossPercent)
}
if len(s.Curve) != 2 || s.Curve[1].SocPercent != nil || *s.Curve[0].SocPercent != 21 {
t.Fatalf("curve not kept as sent: %+v", s.Curve)
}
}
func TestChargingSessionMustMatchItsPath(t *testing.T) {
if _, err := validateChargingSession(chargingBody(""), "22222222-2222-4333-8444-555555555555"); !isValidationError(err) {
t.Fatalf("want ValidationError, got %v", err)
}
}
func TestChargingSessionRejectsOversizedCurvesAndBadPoints(t *testing.T) {
points := strings.Repeat(`{"timestampMs":1,"powerKw":1},`, 721)
long := []byte(`{"installationId":"` + testInstallation + `","sessionId":"` + testSession + `","curve":[` +
strings.TrimSuffix(points, ",") + `]}`)
if _, err := validateChargingSession(long, testSession); !isValidationError(err) {
t.Errorf("721 points: want ValidationError, got %v", err)
}
bad := []byte(strings.Replace(string(chargingBody("")), `"powerKw":95`, `"powerKw":-5`, 1))
if _, err := validateChargingSession(bad, testSession); !isValidationError(err) {
t.Errorf("negative power: want ValidationError, got %v", err)
}
}
func telemetryBody(samples string) []byte {
return []byte(`{"installationId":"` + testInstallation + `","samples":[` + samples + `]}`)
}
func TestTelemetryKeepsOnlyWhatWasSent(t *testing.T) {
b, err := validateTelemetryBatch(telemetryBody(
`{"utc":1790000000,"soc":81,"power":-12.5,"speed":0,"lat":52.1,"lon":7.6,"is_charging":1,"is_dcfc":false}`))
if err != nil {
t.Fatal(err)
}
s := b.Samples[0]
if s.UTC != testNowS || s.Numbers["soc"] != 81 || s.Numbers["power"] != -12.5 || s.Numbers["lat"] != 52.1 {
t.Fatalf("numbers %+v", s.Numbers)
}
if v, ok := s.Numbers["speed"]; !ok || v != 0 {
t.Fatal("a real 0 must be kept")
}
if s.Flags["is_charging"] != true || s.Flags["is_dcfc"] != false || len(s.Flags) != 2 {
t.Fatalf("flags %+v", s.Flags)
}
// An unread signal stays absent: a missing SOC must never become 0.
if _, ok := s.Numbers["est_battery_range"]; ok {
t.Fatal("absent field invented")
}
}
func TestTelemetryDropsUnknownFields(t *testing.T) {
b, err := validateTelemetryBatch(telemetryBody(`{"utc":1790000000,"future_field":3}`))
if err != nil {
t.Fatal(err)
}
if len(b.Samples[0].Numbers) != 0 || len(b.Samples[0].Flags) != 0 {
t.Fatalf("unknown field kept: %+v", b.Samples[0])
}
}
func TestTelemetryRejectsBadSamples(t *testing.T) {
for name, sample := range map[string]string{
"milliseconds": `{"utc":1790000000000}`,
"fractional utc": `{"utc":1790000000.5}`,
"soc above 100": `{"utc":1790000000,"soc":101}`,
"string speed": `{"utc":1790000000,"speed":"fast"}`,
"flag 2": `{"utc":1790000000,"is_parked":2}`,
"half position": `{"utc":1790000000,"lat":52.1}`,
"not an object": `5`,
} {
if _, err := validateTelemetryBatch(telemetryBody(sample)); !isValidationError(err) {
t.Errorf("%s: want ValidationError, got %v", name, err)
}
}
}
func TestTelemetryRejectsEmptyAndOversizedBatches(t *testing.T) {
if _, err := validateTelemetryBatch(telemetryBody("")); !isValidationError(err) {
t.Error("empty batch accepted")
}
many := strings.TrimSuffix(strings.Repeat(`{"utc":1790000000},`, 501), ",")
if _, err := validateTelemetryBatch(telemetryBody(many)); !isValidationError(err) {
t.Error("501 samples accepted")
}
if _, err := validateTelemetryBatch([]byte(`{"installationId":"nope","samples":[{"utc":1790000000}]}`)); !isValidationError(err) {
t.Error("bad installation id accepted")
}
}