From 5304d98a8c0214f4c9c1bd638b78c6512624cf0f Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 16 Jun 2026 11:03:36 +0200 Subject: [PATCH] Vehicle (EU Data Act): prefer most recently delivered value per field (#30916) --- vehicle/vw/eudataact/eudataact_test.go | 36 +++++++++++++++++++++++--- vehicle/vw/eudataact/provider.go | 10 ++++--- vehicle/vw/eudataact/store.go | 15 +++++++---- vehicle/vw/eudataact/types.go | 4 ++- 4 files changed, 52 insertions(+), 13 deletions(-) diff --git a/vehicle/vw/eudataact/eudataact_test.go b/vehicle/vw/eudataact/eudataact_test.go index 7eb356d20..30fa69ec3 100644 --- a/vehicle/vw/eudataact/eudataact_test.go +++ b/vehicle/vw/eudataact/eudataact_test.go @@ -99,7 +99,7 @@ func TestMerge(t *testing.T) { FieldRangeSecondary: {Value: "200", Timestamp: t1}, // new field -> added } - merge(dst, src) + merge(dst, src, 1) assert.Equal(t, "80", dst[FieldSoc].Value, "newer dataset wins, even with an older timestampUtc") assert.Equal(t, "100", dst[FieldOdometer].Value, "field absent from src is retained") @@ -117,18 +117,48 @@ func TestMergeDeliveryOrder(t *testing.T) { data := map[string]point{} // datasets in delivery order; capture timestamps go backwards as SoC rises - for _, d := range []struct{ value, capture string }{ + for i, d := range []struct{ value, capture string }{ {"60", "2026-06-13 14:03:37"}, // delivered 14:10 {"65", "2026-06-13 12:44:31"}, // delivered 14:55, older capture {"66", "2026-06-13 13:03:16"}, // delivered 15:11 {"75", "2026-06-13 13:52:11"}, // delivered 16:54 } { - merge(data, map[string]point{FieldSoc: {Value: d.value, Timestamp: parse(d.capture)}}) + merge(data, map[string]point{FieldSoc: {Value: d.value, Timestamp: parse(d.capture)}}, uint64(i+1)) } assert.Equal(t, "75", data[FieldSoc].Value, "the newest delivered SoC wins") } +// TestSocFreshestField reproduces issue #30877: a higher-priority SoC field that +// stops being delivered must not shadow a lower-priority field that keeps rising. +func TestSocFreshestField(t *testing.T) { + data := map[string]point{} + var seq uint64 + deliver := func(fields map[string]point) { + seq++ + merge(data, fields, seq) + } + + // first datasets carry both SoC fields at 57, the high-priority field winning + deliver(map[string]point{ + FieldBatteryStateReportSoc: {Value: "57"}, + FieldHvBatteryLevel: {Value: "57.0"}, + }) + deliver(map[string]point{ + FieldBatteryStateReportSoc: {Value: "57"}, + FieldHvBatteryLevel: {Value: "57.0"}, + }) + + // later datasets only refresh the fallback field as the car charges + for _, v := range []string{"58.0", "59.0", "61.0"} { + deliver(map[string]point{FieldHvBatteryLevel: {Value: v}}) + } + + soc, err := testProvider(data).Soc() + require.NoError(t, err) + assert.Equal(t, 61.0, soc, "the still-updating fallback wins over the stale high-priority field") +} + // TestPoints guards that a data point with a generic field name ("value") is // indexed by its unique key while the name stays indexed (and thus logged). func TestPoints(t *testing.T) { diff --git a/vehicle/vw/eudataact/provider.go b/vehicle/vw/eudataact/provider.go index c6fd2b0bc..200131a7b 100644 --- a/vehicle/vw/eudataact/provider.go +++ b/vehicle/vw/eudataact/provider.go @@ -60,14 +60,16 @@ func resetDelay(ts time.Time, cache time.Duration) time.Duration { return portalLatency } -// lookup returns the first present, non-empty value among the given field names +// lookup returns the freshest present value among the given field names (most to +// least authoritative); the highest Seq wins, equal Seq keeps the priority order. func lookup(data map[string]point, fields ...string) *point { + var best *point for _, f := range fields { - if v, ok := data[f]; ok { - return new(v) + if v, ok := data[f]; ok && (best == nil || v.Seq > best.Seq) { + best = new(v) } } - return nil + return best } var _ api.Battery = (*Provider)(nil) diff --git a/vehicle/vw/eudataact/store.go b/vehicle/vw/eudataact/store.go index bde3aed3b..5fa854542 100644 --- a/vehicle/vw/eudataact/store.go +++ b/vehicle/vw/eudataact/store.go @@ -29,6 +29,7 @@ type vehicleState struct { identifier string data map[string]point after time.Time + seq uint64 // delivery counter, incremented per merged dataset } var ( @@ -127,7 +128,8 @@ func (s *store) update(log *log.Logger, vin string) (time.Time, error) { v.after = d.CreatedOn } - merge(v.data, data) + v.seq++ + merge(v.data, data, v.seq) if !initial { logData(s.api.log, data) @@ -187,8 +189,11 @@ func pending(content []dataset, after time.Time) []dataset { return res } -// merge lets src (the newer dataset) win per field. The portal's per-field -// timestampUtc is unreliable, so delivery order decides, not the timestamp. -func merge(dst, src map[string]point) { - maps.Copy(dst, src) +// merge lets src (the newer dataset) win per field and stamps each field with +// seq, the dataset's delivery sequence (timestampUtc is unreliable). +func merge(dst, src map[string]point, seq uint64) { + for k, p := range src { + p.Seq = seq + dst[k] = p + } } diff --git a/vehicle/vw/eudataact/types.go b/vehicle/vw/eudataact/types.go index bbc9e2aca..b40bbbafc 100644 --- a/vehicle/vw/eudataact/types.go +++ b/vehicle/vw/eudataact/types.go @@ -90,10 +90,12 @@ type dataPoint struct { TimestampUtc *time.Time `json:"timestampUtc"` } -// point is a decoded data point: its value and the time it was recorded +// point is a decoded data point: its value, the time it was recorded and the +// delivery sequence of the dataset it last arrived in (higher Seq is newer). type point struct { Value string Timestamp time.Time + Seq uint64 } // datasetFile is the JSON document contained in a dataset zip archive