Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

## [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.
- 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.

### saj-emanager 0.1.2

The NovaCore device-support registry accepts this driver.
Expand Down
4 changes: 2 additions & 2 deletions SUPPORT_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion devices.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
Expand Down
85 changes: 74 additions & 11 deletions drivers/lua/vag_vehicle.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -961,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
Expand All @@ -973,11 +991,55 @@ 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 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
if rec.name and rec.name ~= "" and point_is_newer(rec, points[rec.name]) then
points[rec.name] = rec
end
end
end
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
return points
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.
Expand Down Expand Up @@ -1325,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")
Expand Down
64 changes: 64 additions & 0 deletions drivers/tests/lua_harness/test_vag_vehicle_timestamp.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
-- 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, diagnostics=log_soc_selection}"))()
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")

-- 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))
9 changes: 9 additions & 0 deletions drivers/tests/test_vag_vehicle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 3 additions & 3 deletions index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: 50523
sha256: "3b7d8ea5e3129979ef66601b1e18ce5c5771305e5d9d7c9a6342824b5ee59639"
- name: "varta"
version: "1.1.1"
tier: community
Expand Down
6 changes: 3 additions & 3 deletions manifests/vag_vehicle.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: "vag_vehicle"
version: "0.3.0"
version: "0.3.1"
tier: community
author: "Sourceful Labs AB"
protocol: http
Expand All @@ -21,9 +21,9 @@ upstream_docs:
kind: other
url_stability: stable
min_host_version: "2.0.0"
size_bytes: 47864
size_bytes: 50523
dkb_id: "vag_vehicle"
sha256: "fcaa4a44ce26438f3c89d26e8ed3056ea4a195a3516a9ca9ed698c4eca42c5d3"
sha256: "3b7d8ea5e3129979ef66601b1e18ce5c5771305e5d9d7c9a6342824b5ee59639"
signature: ""

bytecode_sha256: ""
2 changes: 1 addition & 1 deletion support-status.json
Original file line number Diff line number Diff line change
Expand Up @@ -1766,7 +1766,7 @@
},
{
"catalog_source": true,
"catalog_version": "0.3.0",
"catalog_version": "0.3.1",
"driver_id": "vag_vehicle",
"targets": {
"blixt-l1": {
Expand Down
Loading