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
353 lines
11 KiB
Go
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
|
|
}
|