Telemetry no longer loses queued batches. The server drops a bad value, or a sample without a plausible time, instead of rejecting the batch, and answers 200 with what it dropped. The app sends only values inside the server's ranges (speed as a magnitude, so reversing is fine) and no samples before the head unit's clock is set. Server: - Pairing hands out and accepts a token only after the device is saved; a failed save leaves the request pending. - A paired token serves only the installation id it was paired with (403 otherwise); the shared INGEST_TOKEN still serves any. - Wrong-password lockouts and open pairing requests are limited per client address (last X-Forwarded-For entry), so a stranger cannot lock the owner out; a car starting again replaces its own request. - ABRP forwarding skips samples stamped ahead of the server's clock, which used to stop it until a restart. App: - Settings written to plaintext during a keystore failure move back into encrypted storage; the stored installation id wins. - The installation id is derived from ANDROID_ID on first start, so a reinstall finds its history; a stored or imported id still wins. - The scanned config file is deleted after import (it holds the token). - GPS stops while live telemetry is off or the server unconfigured, and pauses while parked and idle, keeping the fix the car parked with. - Range estimates use the monotonic clock. - One HTTP helper (ServerHttp) for telemetry, sync and pairing. - Last Turkish strings translated; mise logs shows the NilsMG4 tags. Grafana reads InfluxDB with its own read-only token (new grafana-influx Secret, created by hand as described in k8s/README.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
134 lines
4.8 KiB
Go
134 lines
4.8 KiB
Go
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")
|
|
}
|
|
// A sample from a clock a day ahead is skipped, and does not block the real ones after it.
|
|
ahead := &Forwarder{MinInterval: 5 * time.Second}
|
|
got, ok = ahead.pick([]TelemetrySample{{UTC: testNowS + 86400}, {UTC: testNowS - 1}}, now)
|
|
if !ok || got.UTC != testNowS-1 {
|
|
t.Fatalf("picked %+v %v", got, ok)
|
|
}
|
|
if _, ok := ahead.pick([]TelemetrySample{{UTC: testNowS + 86400}}, now.Add(time.Minute)); ok {
|
|
t.Fatal("forwarded a sample from the future")
|
|
}
|
|
if _, ok := ahead.pick([]TelemetrySample{{UTC: testNowS + 60}}, now.Add(time.Minute)); !ok {
|
|
t.Fatal("a real sample after a future one was not forwarded")
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|