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 }