From 081ad115827e875b8bbf10b526593c6cc66dce09 Mon Sep 17 00:00:00 2001 From: Segran Date: Sat, 7 Nov 2026 00:23:46 +0100 Subject: [PATCH 1/3] fix(vag_vehicle): select telemetry by measurement timestamp Signed-off-by: Segran --- CHANGELOG.md | 8 +++++ SUPPORT_STATUS.md | 4 +-- devices.yaml | 2 +- drivers/lua/vag_vehicle.lua | 32 +++++++++++++++---- .../test_vag_vehicle_timestamp.lua | 32 +++++++++++++++++++ drivers/tests/test_vag_vehicle.py | 9 ++++++ index.yaml | 6 ++-- manifests/vag_vehicle.yaml | 6 ++-- support-status.json | 2 +- 9 files changed, 85 insertions(+), 16 deletions(-) create mode 100644 drivers/tests/lua_harness/test_vag_vehicle_timestamp.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 352b581..cd7a248 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### vag_vehicle 0.3.1 + +- Choose the newest measured telemetry point by timestamp, both when indexing + duplicate keys/names and when selecting between candidate fields. An older + row later in a portal dataset no longer hides a newer SoC reading. +- Prefer valid timestamps over missing or invalid ones. Equal or unavailable + timestamps retain the existing dataset-order fallback. + ### saj-emanager 0.1.2 The NovaCore device-support registry accepts this driver. diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index f602598..8528dec 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -172,8 +172,8 @@ Catalog source is not proof that a target can install or run a driver. | teslamate_vehicle | 0.2.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | tibber | 1.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | | tibber | 1.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | -| vag_vehicle | 0.3.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| vag_vehicle | 0.3.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| vag_vehicle | 0.3.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| vag_vehicle | 0.3.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | varta | 1.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | | varta | 1.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | victron | 2.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | diff --git a/devices.yaml b/devices.yaml index 7dfd711..4dee5c0 100644 --- a/devices.yaml +++ b/devices.yaml @@ -2088,7 +2088,7 @@ manufacturers: protocols: - protocol: http driver: "vag_vehicle" - version: "0.3.0" + version: "0.3.1" ders: [vehicle] control: false firmware_versions: "" diff --git a/drivers/lua/vag_vehicle.lua b/drivers/lua/vag_vehicle.lua index 7246338..e6963c5 100644 --- a/drivers/lua/vag_vehicle.lua +++ b/drivers/lua/vag_vehicle.lua @@ -73,7 +73,7 @@ DRIVER = { id = "vag_vehicle", name = "VAG Vehicle (EU Data Act)", manufacturer = "Volkswagen Group", - version = "0.3.0", + version = "0.3.1", protocols = { "http" }, capabilities = { "vehicle" }, read_only = true, @@ -930,14 +930,30 @@ local function api_get(path, extra) return r.body end --- The newest point among the candidates wins; on a tie, the earlier --- candidate. Points are numbered in dataset order. +-- Choose by measured timestamp, then dataset order; the same row keeps +-- the earlier candidate priority. +-- Compare measurement times before dataset order. A missing timestamp +-- cannot replace a known one. Equal or unknown times retain row ordering. +local function point_is_newer(candidate, current) + if current == nil then return true end + local ct, bt = iso_epoch(candidate.ts), iso_epoch(current.ts) + if ct ~= nil and bt == nil then return true end + if ct == nil and bt ~= nil then return false end + if ct ~= nil then + if ct ~= bt then return ct > bt end + local cf = tonumber("0." .. (candidate.ts:match("%.(%d+)") or "0")) + local bf = tonumber("0." .. (current.ts:match("%.(%d+)") or "0")) + if cf ~= bf then return cf > bf end + end + return candidate.seq > current.seq +end + local function lookup(points, ids) if not points then return nil end local best = nil for i = 1, #ids do local p = points[ids[i]] - if p and p.value ~= nil and p.value ~= "" and (best == nil or p.seq > best.seq) then + if p and p.value ~= nil and p.value ~= "" and point_is_newer(p, best) then best = p end end @@ -973,8 +989,12 @@ local function index_points(data) ts = dp.timestampUtc or dp.TimestampUtc, seq = i, } - if rec.key and rec.key ~= "" then points[rec.key] = rec end - if rec.name and rec.name ~= "" then points[rec.name] = rec end + if rec.key and rec.key ~= "" and point_is_newer(rec, points[rec.key]) then + points[rec.key] = rec + end + if rec.name and rec.name ~= "" and point_is_newer(rec, points[rec.name]) then + points[rec.name] = rec + end end end return points diff --git a/drivers/tests/lua_harness/test_vag_vehicle_timestamp.lua b/drivers/tests/lua_harness/test_vag_vehicle_timestamp.lua new file mode 100644 index 0000000..aa33011 --- /dev/null +++ b/drivers/tests/lua_harness/test_vag_vehicle_timestamp.lua @@ -0,0 +1,32 @@ +-- Synthetic telemetry only: exercise the actual local selection functions. +dofile("drivers/tests/lua_harness/host_mock.lua") +local f = assert(io.open("drivers/lua/vag_vehicle.lua", "r")) +local source = f:read("*a") +f:close() +local select = assert(load(source .. "\nreturn {index=index_points, lookup=lookup}"))() +local function row(key, value, ts, name) + return {key=key, value=value, timestampUtc=ts, dataFieldName=name} +end +local old = "2026-09-26T04:00:00Z" +local new = "2026-09-26T07:00:00Z" +local cases = { + {"duplicate key, newest first", {row("s",72,new), row("s",80,old)}, {"s"}}, + {"duplicate key, newest last", {row("s",80,old), row("s",72,new)}, {"s"}}, + {"candidate GUIDs", {row("a",72,new), row("b",80,old)}, {"a","b"}}, + {"candidate priority", {row("a",80,old), row("b",72,new)}, {"a","b"}}, + {"shared name", {row("a",72,new,"s"), row("b",80,old,"s")}, {"s"}}, + {"missing timestamp", {row("s",72,new), row("s",80,nil)}, {"s"}}, + {"invalid timestamp", {row("s",80,"invalid"), row("s",72,new)}, {"s"}}, + {"unknown times fallback", {row("s",80,nil), row("s",72,nil)}, {"s"}}, + {"equal times fallback", {row("s",80,new), row("s",72,new)}, {"s"}}, + {"timezone offset", {row("a",72,"2026-09-26T08:00:01+01:00"), row("b",80,new)}, {"a","b"}}, + {"fractional seconds", {row("s",72,"2026-09-26T07:00:00.900Z"), row("s",80,"2026-09-26T07:00:00.100Z")}, {"s"}}, + {"empty value", {row("s",72,new), row("s","",new)}, {"s"}}, +} +for _, c in ipairs(cases) do + local got = select.lookup(select.index(c[2]), c[3]) + assert(got and got.value == "72", c[1]) +end +assert(select.lookup(nil, {"s"}) == nil) +assert(select.lookup(select.index(nil), {"s"}) == nil) +print("vag_vehicle timestamps: ok") diff --git a/drivers/tests/test_vag_vehicle.py b/drivers/tests/test_vag_vehicle.py index a231788..95a8325 100644 --- a/drivers/tests/test_vag_vehicle.py +++ b/drivers/tests/test_vag_vehicle.py @@ -142,3 +142,12 @@ def test_vag_vehicle_plug_state(tmp_path: Path) -> None: ) assert result.returncode == 0, result.stdout + result.stderr assert "vag_vehicle plug: ok" in result.stdout + + +def test_vag_vehicle_timestamp_selection() -> None: + result = subprocess.run( + [str(ROOT / "lua55"), "drivers/tests/lua_harness/test_vag_vehicle_timestamp.lua"], + cwd=ROOT, text=True, capture_output=True, check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "vag_vehicle timestamps: ok" in result.stdout diff --git a/index.yaml b/index.yaml index 1a99d9a..e4d019b 100644 --- a/index.yaml +++ b/index.yaml @@ -777,15 +777,15 @@ drivers: size_bytes: 12472 sha256: "3cf10b93755fa8b840375f53343e91b5efa98ddffe119a5679b43ee4bfb61c88" - name: "vag_vehicle" - version: "0.3.0" + version: "0.3.1" tier: community protocol: http connectivity: cloud setup: [vendor_portal] ders: [vehicle] control: false - size_bytes: 47864 - sha256: "fcaa4a44ce26438f3c89d26e8ed3056ea4a195a3516a9ca9ed698c4eca42c5d3" + size_bytes: 48643 + sha256: "c6751f5be65109efe144b797a66582e8962495b94213912a6e4e3a6536768caf" - name: "varta" version: "1.1.1" tier: community diff --git a/manifests/vag_vehicle.yaml b/manifests/vag_vehicle.yaml index 9ca9848..8eb691d 100644 --- a/manifests/vag_vehicle.yaml +++ b/manifests/vag_vehicle.yaml @@ -1,5 +1,5 @@ name: "vag_vehicle" -version: "0.3.0" +version: "0.3.1" tier: community author: "Sourceful Labs AB" protocol: http @@ -21,9 +21,9 @@ upstream_docs: kind: other url_stability: stable min_host_version: "2.0.0" -size_bytes: 47864 +size_bytes: 48643 dkb_id: "vag_vehicle" -sha256: "fcaa4a44ce26438f3c89d26e8ed3056ea4a195a3516a9ca9ed698c4eca42c5d3" +sha256: "c6751f5be65109efe144b797a66582e8962495b94213912a6e4e3a6536768caf" signature: "" bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index 309845d..8fc4a42 100644 --- a/support-status.json +++ b/support-status.json @@ -1766,7 +1766,7 @@ }, { "catalog_source": true, - "catalog_version": "0.3.0", + "catalog_version": "0.3.1", "driver_id": "vag_vehicle", "targets": { "blixt-l1": { From 071ec79aff9570252834c8b09454c6fd94514e0d Mon Sep 17 00:00:00 2001 From: Segran Date: Sat, 7 Nov 2026 00:30:55 +0100 Subject: [PATCH 2/3] feat(vag_vehicle): explain SoC selection for each portal dataset Signed-off-by: Segran --- CHANGELOG.md | 2 + drivers/lua/vag_vehicle.lua | 53 +++++++++++++++++-- .../test_vag_vehicle_timestamp.lua | 34 +++++++++++- manifests/vag_vehicle.yaml | 4 +- 4 files changed, 85 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd7a248..a067ed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ - Choose the newest measured telemetry point by timestamp, both when indexing duplicate keys/names and when selecting between candidate fields. An older row later in a portal dataset no longer hides a newer SoC reading. +- Log a VIN-masked dataset summary and bounded SoC candidate details, including + discarded rows, selected row, fetch time and selection reason. - Prefer valid timestamps over missing or invalid ones. Equal or unavailable timestamps retain the existing dataset-order fallback. diff --git a/drivers/lua/vag_vehicle.lua b/drivers/lua/vag_vehicle.lua index e6963c5..0a0d31a 100644 --- a/drivers/lua/vag_vehicle.lua +++ b/drivers/lua/vag_vehicle.lua @@ -977,8 +977,10 @@ local function upper(s) end local function index_points(data) - local points = {} - if type(data) ~= "table" then return points end + local points, soc_candidates = {}, {} + local soc_ids = {} + for _, id in ipairs(SOC_IDS) do soc_ids[id] = true end + if type(data) ~= "table" then return points, soc_candidates end for i = 1, #data do local dp = data[i] if type(dp) == "table" and dp.value ~= nil and tostring(dp.value) ~= "" then @@ -989,6 +991,9 @@ local function index_points(data) ts = dp.timestampUtc or dp.TimestampUtc, seq = i, } + if soc_ids[rec.key] or soc_ids[rec.name] then + soc_candidates[#soc_candidates + 1] = rec + end if rec.key and rec.key ~= "" and point_is_newer(rec, points[rec.key]) then points[rec.key] = rec end @@ -997,7 +1002,44 @@ local function index_points(data) end end end - return points + return points, soc_candidates +end + +-- One bounded diagnostic per downloaded dataset. Keep only SoC fields, +-- including candidates discarded during indexing; never log the raw dataset. +local function log_soc_selection(filename, candidates, selected) + local function safe(value) + return tostring(value or "unknown"):gsub("[^%w%._:%+%-]", "_"):sub(1, 100) + end + local reason = "no_candidate" + if selected then + local ts = iso_epoch(selected.ts) + reason = ts and "timestamp" or "dataset_order_no_valid_timestamp" + if ts then + local fraction = selected.ts:match("%.(%d+)") or "0" + for _, p in ipairs(candidates) do + if p ~= selected and iso_epoch(p.ts) == ts and + tonumber("0." .. (p.ts:match("%.(%d+)") or "0")) == tonumber("0." .. fraction) then + reason = "timestamp_then_dataset_order" + end + end + end + end + log("info", "vag: soc selection file=" .. safe(filename) .. + " fetched_at_epoch_s=" .. tostring(server_now_s or "unknown") .. + " candidates=" .. tostring(#candidates) .. + " selected_row=" .. tostring(selected and selected.seq or "none") .. + " reason=" .. reason) + for i = 1, math.min(#candidates, 32) do + local p = candidates[i] + log("info", "vag: soc candidate row=" .. tostring(p.seq) .. + " key=" .. safe(p.key) .. " name=" .. safe(p.name) .. + " value=" .. safe(p.value) .. " timestamp=" .. safe(p.ts) .. + " selected=" .. tostring(p == selected)) + end + if #candidates > 32 then + log("info", "vag: soc candidates truncated=" .. tostring(#candidates - 32)) + end end -- Tesla-shaped vocabulary Core ranks in telemetry.VehicleConnectedRank. @@ -1345,14 +1387,15 @@ function driver_poll() -- Read each file once, even one that turns out useless. last_file = name - local points, perr = parse_dataset(zip) + local points, candidates_or_error = parse_dataset(zip) if not points then - log("warn", "vag: dataset decode failed: " .. tostring(perr)) + log("warn", "vag: dataset decode failed: " .. tostring(candidates_or_error)) emit_last() return POLL_INTERVAL_MS end local soc_p = lookup(points, SOC_IDS) + log_soc_selection(name, candidates_or_error or {}, soc_p) local soc = soc_p and num(soc_p.value) or nil if soc == nil then log("debug", "vag: dataset had no SoC field") diff --git a/drivers/tests/lua_harness/test_vag_vehicle_timestamp.lua b/drivers/tests/lua_harness/test_vag_vehicle_timestamp.lua index aa33011..ca6c669 100644 --- a/drivers/tests/lua_harness/test_vag_vehicle_timestamp.lua +++ b/drivers/tests/lua_harness/test_vag_vehicle_timestamp.lua @@ -3,7 +3,7 @@ dofile("drivers/tests/lua_harness/host_mock.lua") local f = assert(io.open("drivers/lua/vag_vehicle.lua", "r")) local source = f:read("*a") f:close() -local select = assert(load(source .. "\nreturn {index=index_points, lookup=lookup}"))() +local select = assert(load(source .. "\nreturn {index=index_points, lookup=lookup, diagnostics=log_soc_selection}"))() local function row(key, value, ts, name) return {key=key, value=value, timestampUtc=ts, dataFieldName=name} end @@ -30,3 +30,35 @@ end assert(select.lookup(nil, {"s"}) == nil) assert(select.lookup(select.index(nil), {"s"}) == nil) print("vag_vehicle timestamps: ok") + +-- Diagnostics retain overwritten rows and explain the selection without raw data. +local points, candidates = select.index({row("s",72,new), row("s",80,old)}) +-- Use recognized names for collection, while retaining the duplicate-key test. +points, candidates = select.index({row("s",72,new,"state_of_charge"), row("s",80,old,"state_of_charge")}) +assert(#candidates == 2) +select.diagnostics("synthetic.zip", candidates, select.lookup(points,{"s"})) +local logs = table.concat(host._logs, "\n") +assert(logs:find("candidates=2",1,true)) +assert(logs:find("selected_row=1 reason=timestamp",1,true)) +assert(logs:find("row=2 key=s",1,true)) +select.diagnostics("synthetic.zip", {}, nil) +assert(table.concat(host._logs,"\n"):find("reason=no_candidate",1,true)) + +local equal, equal_candidates = select.index({ + row("s",80,new,"state_of_charge"), row("s",72,new,"state_of_charge")}) +select.diagnostics("synthetic.zip", equal_candidates, select.lookup(equal,{"s"})) +assert(table.concat(host._logs,"\n"):find("reason=timestamp_then_dataset_order",1,true)) +local missing, missing_candidates = select.index({row("s",72,nil,"state_of_charge")}) +select.diagnostics("synthetic.zip", missing_candidates, select.lookup(missing,{"s"})) +assert(table.concat(host._logs,"\n"):find("reason=dataset_order_no_valid_timestamp",1,true)) +local many = {} +for i=1,40 do many[i]=row("s",72,new,"state_of_charge") end +local indexed, collected = select.index(many) +select.diagnostics("synthetic.zip", collected, select.lookup(indexed,{"s"})) +assert(table.concat(host._logs,"\n"):find("truncated=8",1,true)) +local vin = "WVWZZZTESTVIN0001" +driver_init({vin=vin,brand="volkswagen",cookie="synthetic"}) +select.diagnostics("20260926070000_"..vin..".zip", {}, nil) +local masked = host._logs[#host._logs] +assert(not masked:find(vin,1,true)) +assert(masked:find("****0001",1,true)) diff --git a/manifests/vag_vehicle.yaml b/manifests/vag_vehicle.yaml index 8eb691d..eef4dd9 100644 --- a/manifests/vag_vehicle.yaml +++ b/manifests/vag_vehicle.yaml @@ -21,9 +21,9 @@ upstream_docs: kind: other url_stability: stable min_host_version: "2.0.0" -size_bytes: 48643 +size_bytes: 50523 dkb_id: "vag_vehicle" -sha256: "c6751f5be65109efe144b797a66582e8962495b94213912a6e4e3a6536768caf" +sha256: "3b7d8ea5e3129979ef66601b1e18ce5c5771305e5d9d7c9a6342824b5ee59639" signature: "" bytecode_sha256: "" From d25c684ce30a8dc1ecae473d0daaa492275577e8 Mon Sep 17 00:00:00 2001 From: Segran Date: Sat, 7 Nov 2026 00:31:27 +0100 Subject: [PATCH 3/3] chore(vag_vehicle): refresh generated diagnostic artifact metadata Signed-off-by: Segran --- index.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/index.yaml b/index.yaml index e4d019b..69daa8a 100644 --- a/index.yaml +++ b/index.yaml @@ -784,8 +784,8 @@ drivers: setup: [vendor_portal] ders: [vehicle] control: false - size_bytes: 48643 - sha256: "c6751f5be65109efe144b797a66582e8962495b94213912a6e4e3a6536768caf" + size_bytes: 50523 + sha256: "3b7d8ea5e3129979ef66601b1e18ce5c5771305e5d9d7c9a6342824b5ee59639" - name: "varta" version: "1.1.1" tier: community