Files
mg4/server/validate.go
T
nilsandClaude Opus 5.5 ca2f899403 App 2.11.0, server 2.3.0: live data straight to ABRP; settings regrouped
ABRP keeps getting live data while the car server is down: with an ABRP
API key and user token set in the app (Live data tab, or abrp_api_key /
abrp_token in the config file), the car sends the newest sample to ABRP
itself at every upload. It tells the server the newest sample ABRP took
(abrpSentUtc), and the server forwards only newer ones, so ABRP gets
each sample once. Without credentials in the app the server forwards
everything, as before. The credentials sit in encrypted storage, go in
the request body, and neither they nor ABRP's answer are logged.

Settings are grouped by what they are for:
- Live data (was Upload): on/off, ABRP credentials, upload frequency.
- Settings (replaces the Wi-Fi tab): full screen and voice key,
  usable capacity and manual SoH (were on Consumption), charging price
  (was on Charging), Wi-Fi.
Charging and Live data get icons of their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 12:15:21 +02:00

353 lines
11 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"math"
"regexp"
"sort"
"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; a flag may be 0/1 or a boolean.
//
// Only a malformed request is a 400. A bad value inside a sample costs that value, and a
// sample without a usable time costs that sample: the car drops a batch the server
// rejects, and one sensor reading out of range must not take hours of queued driving with
// it. What was dropped is listed in Dropped, for the response and the log.
func validateTelemetryBatch(body []byte, now time.Time) (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")
}
batch := TelemetryBatch{InstallationID: id, Samples: make([]TelemetrySample, 0, len(raw))}
if o.has("abrpSentUtc") {
if batch.AbrpSentUTC, err = integer(o["abrpSentUtc"], "abrpSentUtc"); err != nil {
return TelemetryBatch{}, err
}
}
latest := now.Add(maxClockAhead).Unix()
for i, entry := range raw {
name := fmt.Sprintf("samples[%d]", i)
sample, dropped, ok := validateSample(entry, name, latest)
batch.Dropped = append(batch.Dropped, dropped...)
if ok {
batch.Samples = append(batch.Samples, sample)
}
}
return batch, nil
}
// maxClockAhead is how far in the future a sample may be stamped. The head unit sets its
// clock from GPS; a sample from further ahead has a wrong time, and stored there it would
// pose as the car's latest state until that time came.
const maxClockAhead = time.Hour
// validateSample keeps every valid field of one sample. It returns false when the sample
// as a whole is unusable: not an object, or no plausible time. dropped names what was left
// out.
func validateSample(entry any, name string, latestUTC int64) (sample TelemetrySample, dropped []string, ok bool) {
o, err := asObject(entry, name)
if err != nil {
return TelemetrySample{}, []string{name}, false
}
utc, err := integer(o["utc"], name+".utc")
if err != nil || utc < utcMin || utc > utcMax || utc > latestUTC {
return TelemetrySample{}, []string{name + ".utc"}, false
}
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 {
dropped = append(dropped, name+"."+field)
continue
}
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:
dropped = append(dropped, name+"."+field)
}
default:
dropped = append(dropped, name+"."+field)
}
}
// A position is only a position as a pair.
_, hasLat := sample.Numbers["lat"]
_, hasLon := sample.Numbers["lon"]
if hasLat != hasLon {
delete(sample.Numbers, "lat")
delete(sample.Numbers, "lon")
dropped = append(dropped, name+".lat/lon")
}
sort.Strings(dropped)
return sample, dropped, true
}