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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user