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
This commit is contained in:
2026-10-18 12:15:21 +02:00
co-authored by Claude Opus 5.5
parent 087f193f8c
commit ca2f899403
36 changed files with 636 additions and 163 deletions
+3 -1
View File
@@ -40,7 +40,9 @@ with (403 otherwise); the shared token works for any.
a plausible time (before 2001, or more than an hour ahead) is dropped whole. The rest is stored
and the answer is `200 {stored, droppedCount, dropped}`, even when nothing was usable, since
the car would only send it again. The newest sample is forwarded to ABRP if it is under
5 minutes old and not ahead of the server's clock.
5 minutes old and not ahead of the server's clock. A car that sends to ABRP itself adds
`abrpSentUtc`, the newest sample ABRP took from it; only samples newer than that are
forwarded, so ABRP gets each one once.
- `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.
+15 -1
View File
@@ -117,6 +117,20 @@ func (a *API) ingest(h handler) http.HandlerFunc {
}
}
// notYetAtAbrp leaves out the samples the car already sent to ABRP itself.
func notYetAtAbrp(samples []TelemetrySample, sentUTC int64) []TelemetrySample {
if sentUTC == 0 {
return samples
}
out := make([]TelemetrySample, 0, len(samples))
for _, s := range samples {
if s.UTC > sentUTC {
out = append(out, s)
}
}
return out
}
// maxDroppedListed caps the dropped values named in a telemetry response and log line.
const maxDroppedListed = 20
@@ -136,7 +150,7 @@ func (a *API) telemetry(w http.ResponseWriter, r *http.Request, c caller, body [
if err := a.Store.WriteTelemetry(r.Context(), batch.InstallationID, batch.Samples); err != nil {
return err
}
a.Forwarder.Forward(batch.Samples)
a.Forwarder.Forward(notYetAtAbrp(batch.Samples, batch.AbrpSentUTC))
}
dropped := batch.Dropped
if len(dropped) > maxDroppedListed {
+20
View File
@@ -204,3 +204,23 @@ func jsonInt(v int64) string {
b, _ := json.Marshal(v)
return string(b)
}
func TestSamplesTheCarSentToAbrpAreNotForwardedAgain(t *testing.T) {
samples := []TelemetrySample{{UTC: 100}, {UTC: 115}, {UTC: 130}}
if got := notYetAtAbrp(samples, 0); len(got) != 3 {
t.Fatalf("without abrpSentUtc everything is forwardable: %d", len(got))
}
if got := notYetAtAbrp(samples, 130); len(got) != 0 {
t.Fatalf("the car sent the newest: %d left", len(got))
}
if got := notYetAtAbrp(samples, 115); len(got) != 1 || got[0].UTC != 130 {
t.Fatalf("only the newer sample: %+v", got)
}
b, err := validateTelemetryBatch([]byte(`{"installationId":"`+testInstallation+`","abrpSentUtc":1790000000,"samples":[{"utc":1790000000}]}`), testNow)
if err != nil || b.AbrpSentUTC != 1790000000 {
t.Fatalf("abrpSentUtc not read: %v %d", err, b.AbrpSentUTC)
}
if _, err := validateTelemetryBatch([]byte(`{"installationId":"`+testInstallation+`","abrpSentUtc":"x","samples":[{"utc":1790000000}]}`), testNow); !isValidationError(err) {
t.Fatal("a malformed abrpSentUtc was accepted")
}
}
+3
View File
@@ -107,4 +107,7 @@ type TelemetryBatch struct {
Samples []TelemetrySample
// Dropped names the values and samples validation left out, e.g. "samples[3].speed".
Dropped []string
// AbrpSentUTC is the newest sample the car delivered to ABRP itself (0: none). The
// server forwards only samples newer than that, so ABRP gets each one once.
AbrpSentUTC int64
}
+5
View File
@@ -273,6 +273,11 @@ func validateTelemetryBatch(body []byte, now time.Time) (TelemetryBatch, error)
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)