From e53653c342f935e53f4cf785758ce064c1059eee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 26 Sep 2026 07:06:33 +0000 Subject: [PATCH 1/3] feat(vag_vehicle): add telemetry-only EU Data Act vehicle driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only VW / Audi / Škoda / SEAT / Cupra SoC from the VW Group EU Data Act portal. We Connect third-party APIs are blocked. The driver emits DerVehicle, binds VIN identity, ages vendor datasets and stops emitting when they are stale. No wake or charge_start. Signed-off-by: Cursor Agent --- CHANGELOG.md | 12 + SUPPORT_STATUS.md | 2 + devices.yaml | 13 + drivers/lua/vag_vehicle.lua | 802 ++++++++++++++++++ .../tests/lua_harness/test_vag_vehicle.lua | 129 +++ drivers/tests/test_vag_vehicle.py | 43 + index.yaml | 10 + manifests/vag_vehicle.yaml | 29 + support-status.json | 21 + 9 files changed, 1061 insertions(+) create mode 100644 drivers/lua/vag_vehicle.lua create mode 100644 drivers/tests/lua_harness/test_vag_vehicle.lua create mode 100644 drivers/tests/test_vag_vehicle.py create mode 100644 manifests/vag_vehicle.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ab0890..0c9f669 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## vag_vehicle 0.1.0 + +Read-only VW / Audi / Škoda / SEAT / Cupra telemetry from the VW Group EU +Data Act portal. We Connect third-party APIs are blocked; this is the remaining +owner door. The driver emits `DerVehicle` (SoC, charge limit, charging_state, +time-to-full, stale, soc_fresh), binds identity with `set_make` / `set_sn` +(VIN), ages the last dataset and stops emitting when it is stale. Cloud access +is not a charging prerequisite. The owner enables a continuous 15-minute All +Data request on the portal and pastes a session Cookie header — the Lua host +has no cookie jar and cannot complete the portal's OIDC login. Porsche, wake +and charge_start are out of scope. + ## esphome_dsmr 1.0.6 The catalog entry `esphome-dsmr` is folded into this one: it was the same driver for the same device under a second id. `esphome_dsmr` declares `replaces = { "esphome-dsmr" }` in its `DRIVER` table, so a host can move a device that runs the old entry to this driver. Every published `esphome-dsmr` artifact stays in the signed channel's history, so an installed copy keeps running until then. diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 7cdcef9..4537365 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -162,6 +162,8 @@ Catalog source is not proof that a target can install or run a driver. | tesla_wall_connector | 0.1.1 | 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.1.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| vag_vehicle | 0.1.0 | 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 48aff76..ec19d72 100644 --- a/devices.yaml +++ b/devices.yaml @@ -2021,6 +2021,19 @@ manufacturers: control: false firmware_versions: "" notes: "Requires Venus OS with Modbus TCP enabled on GX device" + - name: "Volkswagen Group" + model_families: + - name: "VAG Vehicle (EU Data Act)" + variants: [Enyaq, ID.3, ID.4, Q4 e-tron] + regions: [EU] + protocols: + - protocol: http + driver: "vag_vehicle" + version: "0.1.0" + ders: [vehicle] + control: false + firmware_versions: "" + notes: "Read-only SoC and charge state from the VW Group EU Data Act portal. We Connect third-party APIs are blocked. Owner must enable a continuous 15-minute All Data request and paste a portal session cookie. Not live BMS; charging does not need this cloud. Porsche is not on this portal. Not yet exercised on a live FTW site." - name: "Wallbox" model_families: - name: "Commander/Pulsar" diff --git a/drivers/lua/vag_vehicle.lua b/drivers/lua/vag_vehicle.lua new file mode 100644 index 0000000..389591a --- /dev/null +++ b/drivers/lua/vag_vehicle.lua @@ -0,0 +1,802 @@ +-- VAG vehicle telemetry (VW / Audi / Škoda / SEAT / Cupra) +-- Emits: Vehicle (DerVehicle) +-- Protocol: HTTPS — VW Group EU Data Act portal +-- +-- We Connect / CarNet third-party access is blocked. The remaining +-- owner-accessible door is the EU Data Act portal operated by +-- Volkswagen Group Info Services AG: +-- https://eu-data-act.drivesomethinggreater.com/ +-- That portal is not live BMS. After the owner enables a continuous +-- "All Data" request it writes a zipped JSON dataset about every +-- 15 minutes. This driver downloads the newest content file and maps +-- the charging fields Tesla already taught Core: SoC, charge limit, +-- charging_state, time-to-full, freshness. +-- +-- Cloud is optional context, never a charging prerequisite. A missing +-- cookie, a sleeping car, or a 15-minute-old zip must not look like +-- a fresh BMS reading and must not stop the wallbox. +-- +-- Porsche is not on this portal (VW, Audi, Škoda, SEAT, Cupra, MAN, +-- Bentley, Elli). No wake / charge_start / climatisation — telemetry +-- only. FTW's Lua host has no cookie jar and cannot complete the +-- portal's OIDC form login, so the owner pastes a logged-in session +-- Cookie header. When it expires the driver stops emitting. +-- +-- What the site owner must provide: +-- 1. A brand account already linked to the car. +-- 2. One-time consent on the portal, then +-- Get customised data → continuous → All Data → 15 minutes. +-- 3. VIN, brand, and the Cookie header from a logged-in portal tab +-- (DevTools → Network → any portal request → Request Headers). +-- +-- drivers: +-- - name: id4 +-- lua: drivers/vag_vehicle.lua +-- capabilities: +-- http: +-- allowed_hosts: ["eu-data-act.drivesomethinggreater.com"] +-- config: +-- vin: "WVWZZZ..." +-- brand: volkswagen # audi | skoda | seat | cupra +-- cookie: "name=value; ..." # masked via config_secrets +-- +-- Field names and GUIDs follow the portal data dictionary / evcc's +-- drivesomethinggreater mapping (vehicle/vw/eudataact). Look up by +-- GUID first: several points share the name battery_state_report.soc. + +DRIVER = { + host_api_min = 1, + host_api_max = 1, + id = "vag_vehicle", + name = "VAG Vehicle (EU Data Act)", + manufacturer = "Volkswagen Group", + version = "0.1.0", + protocols = { "http" }, + capabilities = { "vehicle" }, + read_only = true, + description = "Read-only VW / Audi / Škoda / SEAT / Cupra SoC and charge state from the EU Data Act portal. Not live BMS; charging does not need this cloud.", + homepage = "https://eu-data-act.drivesomethinggreater.com/", + http_hosts = { "eu-data-act.drivesomethinggreater.com" }, + authors = { "FTW contributors" }, + tested_models = { "ID.3", "ID.4", "Enyaq", "Q4 e-tron" }, + verification_status = "experimental", + config_secrets = { "cookie" }, +} + +PROTOCOL = "http" + +local BASE_URL = "https://eu-data-act.drivesomethinggreater.com" +local POLL_INTERVAL_MS = 120000 +local WATCHDOG_TIMEOUT_S = 600 +-- Portal cadence is ~15 min. Keep a last reading visible a little +-- longer than that, then stop so Core's watchdog sees the gap. +local STALE_AFTER_MS = 2700000 + +local BRANDS = { + volkswagen = "Volkswagen", + vw = "Volkswagen", + volkswagen_passenger_cars = "Volkswagen", + audi = "Audi", + skoda = "Skoda", + seat = "Seat", + cupra = "Cupra", +} + +-- SoC / limit / state / remaining-time ids from the Data Act dictionary. +-- Name fallbacks stay for datasets that omit the GUID. +local SOC_IDS = { + "162c2a75-edf4-3990-b8ed-7c600b3dbc40", -- battery_level_HV.value + "ae0294b4-1286-3e98-a818-1485b8d88430", -- state_of_charge + "f89ed652-d104-3fa6-b7e2-ab7543309e7b", -- hv_soc + "506cb83e-f99f-3af3-bbeb-0429b69a78d9", -- battery_state_report.soc (ID.3) + "0a18a053-b4b0-3db1-be44-a6c5dba629b1", -- Enyaq soc + "battery_level_HV.value", + "state_of_charge", + "hv_soc", +} +local LIMIT_IDS = { + "76acaa98-37ef-3466-b013-21c77ed343ae", -- settings.target_soc + "settings.target_soc", +} +local BCAM_THRESHOLD_IDS = { + "battery_care_mode.charge_bcam_threshold", +} +local BCAM_ACTIVE_IDS = { + "setting.bcam_activation", +} +local CHARGE_STATE_IDS = { + "9da735bb-c5d5-39f8-bf53-0fa2a367aa8f", + "96c211b4-f8fb-3f40-b7cf-6a1cd12cce6d", + "charging_state", + "charging_state_report.current_charge_state", +} +local SCENARIO_IDS = { + "charging_state_report.charging_scenario", +} +local PLUG_IDS = { + "c111830c-f959-30d2-859a-ea996190d864", + "37d8c0c0-9677-3823-8b32-d0a5001cb0d0", + "plug_state", + "charging_plug1_connectionstate", +} +local TTF_IDS = { + "cf28f7d9-6201-30b8-82e5-a461968d30dc", + "remaining_charging_time", +} + +local vin = nil +local brand_name = nil +local cookie = nil +local request_id = nil +local last_file = nil +local last = { + ts_ms = 0, + soc = nil, + charge_limit = nil, + charging_state = nil, + time_to_full = nil, +} + +--------------------------------------------------------------------------- +-- ZIP + raw DEFLATE (Lua 5.1). The host returns the portal zip as a +-- string and has no unzip helper. +--------------------------------------------------------------------------- + +local function u16le(s, i) + local a, b = string.byte(s, i, i + 1) + if not b then return nil end + return a + b * 256 +end + +local function u32le(s, i) + local lo = u16le(s, i) + local hi = u16le(s, i + 2) + if not lo or not hi then return nil end + return lo + hi * 65536 +end + +local function inflate_raw(src) + local pos = 1 + local bitbuf, bitcnt = 0, 0 + local out = {} + + local function pull_byte() + if pos > #src then return nil end + local b = string.byte(src, pos) + pos = pos + 1 + return b + end + + local function bits(n) + while bitcnt < n do + local b = pull_byte() + if b == nil then return nil end + bitbuf = bitbuf + b * (2 ^ bitcnt) + bitcnt = bitcnt + 8 + end + local v = bitbuf % (2 ^ n) + bitbuf = math.floor(bitbuf / (2 ^ n)) + bitcnt = bitcnt - n + return v + end + + local function align() + bitbuf, bitcnt = 0, 0 + end + + local function build_tree(lengths) + local max_len = 0 + for i = 1, #lengths do + if lengths[i] > max_len then max_len = lengths[i] end + end + local bl_count = {} + for i = 0, max_len do bl_count[i] = 0 end + for i = 1, #lengths do + local l = lengths[i] + if l > 0 then bl_count[l] = bl_count[l] + 1 end + end + local next_code = {} + local code = 0 + bl_count[0] = 0 + for len = 1, max_len do + code = (code + bl_count[len - 1]) * 2 + next_code[len] = code + end + local by_len = {} + for i = 1, #lengths do + local l = lengths[i] + if l > 0 then + if not by_len[l] then by_len[l] = {} end + by_len[l][next_code[l]] = i - 1 + next_code[l] = next_code[l] + 1 + end + end + return by_len, max_len + end + + local function decode(tree, max_len) + local code = 0 + for len = 1, max_len do + local b = bits(1) + if b == nil then return nil end + code = code * 2 + b + local row = tree[len] + if row and row[code] ~= nil then + return row[code] + end + end + return nil + end + + local LEN_EXTRA = { + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, + 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0, + } + local LEN_BASE = { + 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, + 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258, + } + local DIST_EXTRA = { + 0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, + 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13, + } + local DIST_BASE = { + 1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, + 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, + 8193, 12289, 16385, 24577, + } + + local function fixed_lit_lengths() + local lengths = {} + for i = 0, 143 do lengths[i + 1] = 8 end + for i = 144, 255 do lengths[i + 1] = 9 end + for i = 256, 279 do lengths[i + 1] = 7 end + for i = 280, 287 do lengths[i + 1] = 8 end + return lengths + end + + local function fixed_dist_lengths() + local lengths = {} + for i = 1, 32 do lengths[i] = 5 end + return lengths + end + + local CL_ORDER = { + 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15, + } + + local function read_dynamic() + local hlit = bits(5) + local hdist = bits(5) + local hclen = bits(4) + if not hlit or not hdist or not hclen then return nil end + hlit, hdist, hclen = hlit + 257, hdist + 1, hclen + 4 + local cl_lengths = {} + for i = 1, 19 do cl_lengths[i] = 0 end + for i = 1, hclen do + local n = bits(3) + if n == nil then return nil end + cl_lengths[CL_ORDER[i] + 1] = n + end + local cl_tree, cl_max = build_tree(cl_lengths) + local function read_lengths(count) + local lengths = {} + local i = 1 + local prev = 0 + while i <= count do + local sym = decode(cl_tree, cl_max) + if sym == nil then return nil end + if sym < 16 then + lengths[i] = sym + prev = sym + i = i + 1 + else + local reps, fill + if sym == 16 then + local extra = bits(2) + if extra == nil then return nil end + reps, fill = extra + 3, prev + elseif sym == 17 then + local extra = bits(3) + if extra == nil then return nil end + reps, fill = extra + 3, 0 + else + local extra = bits(7) + if extra == nil then return nil end + reps, fill = extra + 11, 0 + end + for _ = 1, reps do + lengths[i] = fill + i = i + 1 + end + if fill ~= 0 then prev = fill end + end + end + return lengths + end + local lit = read_lengths(hlit) + local dist = read_lengths(hdist) + if not lit or not dist then return nil end + return lit, dist + end + + local function inflate_block(lit_tree, lit_max, dist_tree, dist_max) + while true do + local sym = decode(lit_tree, lit_max) + if sym == nil then return nil end + if sym < 256 then + out[#out + 1] = string.char(sym) + elseif sym == 256 then + return true + else + local li = sym - 256 + if li < 1 or li > #LEN_BASE then return nil end + local extra = bits(LEN_EXTRA[li]) + if extra == nil then return nil end + local length = LEN_BASE[li] + extra + local dsym = decode(dist_tree, dist_max) + if dsym == nil or dsym < 0 or dsym > 29 then return nil end + extra = bits(DIST_EXTRA[dsym + 1]) + if extra == nil then return nil end + local dist = DIST_BASE[dsym + 1] + extra + local start = #out - dist + 1 + if start < 1 then return nil end + for i = 1, length do + out[#out + 1] = out[start + i - 1] + end + end + end + end + + while true do + local bfinal = bits(1) + local btype = bits(2) + if bfinal == nil or btype == nil then return nil end + if btype == 0 then + align() + if pos + 3 > #src then return nil end + local len = u16le(src, pos) + local nlen = u16le(src, pos + 2) + pos = pos + 4 + if not len or not nlen or (len + nlen) ~= 65535 then return nil end + if pos + len - 1 > #src then return nil end + -- One byte per slot so later length/distance copies stay correct. + for j = pos, pos + len - 1 do + out[#out + 1] = string.sub(src, j, j) + end + pos = pos + len + elseif btype == 1 or btype == 2 then + local lit, dist + if btype == 1 then + lit, dist = fixed_lit_lengths(), fixed_dist_lengths() + else + lit, dist = read_dynamic() + if not lit then return nil end + end + local lit_tree, lit_max = build_tree(lit) + local dist_tree, dist_max = build_tree(dist) + if not inflate_block(lit_tree, lit_max, dist_tree, dist_max) then + return nil + end + else + return nil + end + if bfinal == 1 then + return table.concat(out) + end + end +end + +local function zip_extract_json(blob) + if type(blob) ~= "string" or blob == "" then + return nil, "empty zip" + end + local first = blob:match("^%s*(.)") + if first == "{" or first == "[" then + return blob + end + local i = 1 + while i + 30 <= #blob do + if string.sub(blob, i, i + 3) ~= "PK\003\004" then + i = i + 1 + else + local method = u16le(blob, i + 8) + local comp_size = u32le(blob, i + 18) + local name_len = u16le(blob, i + 26) + local extra_len = u16le(blob, i + 28) + if not method or not comp_size or not name_len or not extra_len then + return nil, "truncated zip header" + end + local name_at = i + 30 + local data_at = name_at + name_len + extra_len + local name = string.sub(blob, name_at, name_at + name_len - 1) + local payload = string.sub(blob, data_at, data_at + comp_size - 1) + if #payload < comp_size then + return nil, "truncated zip payload" + end + if name:lower():match("%.json$") then + if method == 0 then + return payload + elseif method == 8 then + local raw = inflate_raw(payload) + if not raw then return nil, "deflate failed" end + return raw + else + return nil, "unsupported zip method " .. tostring(method) + end + end + i = data_at + comp_size + end + end + return nil, "no json document in dataset" +end + +--------------------------------------------------------------------------- +-- HTTP + mapping +--------------------------------------------------------------------------- + +local function safe_http_get(url, headers) + local ok, resp, err = pcall(host.http_get, url, headers) + if not ok then return nil, tostring(resp) end + return resp, err +end + +local function safe_json_decode(body) + if body == nil then return nil, "empty" end + local ok, data, derr = pcall(host.json_decode, body) + if not ok then return nil, tostring(data) end + if data == nil then return nil, tostring(derr or "empty JSON") end + return data +end + +local function auth_headers(extra) + local h = { + ["Accept"] = "application/json", + ["Cookie"] = cookie, + } + if extra then + for k, v in pairs(extra) do + h[k] = v + end + end + return h +end + +local function api_get(path, extra) + return safe_http_get(BASE_URL .. path, auth_headers(extra)) +end + +local function lookup(points, ids) + if not points then return nil end + for i = 1, #ids do + local p = points[ids[i]] + if p and p.value ~= nil and p.value ~= "" then + return p + end + end + return nil +end + +local function num(value) + if type(value) == "number" then return value end + if type(value) ~= "string" then return nil end + return tonumber(value:match("[-+]?%d+%.?%d*")) +end + +local function lower(s) + if type(s) ~= "string" then return "" end + return string.lower(s) +end + +local function upper(s) + if type(s) ~= "string" then return "" end + return string.upper(s) +end + +local function index_points(data) + local points = {} + if type(data) ~= "table" then return points end + for i = 1, #data do + local dp = data[i] + if type(dp) == "table" and dp.value ~= nil and tostring(dp.value) ~= "" then + local rec = { + value = tostring(dp.value), + key = dp.key, + name = dp.dataFieldName or dp.DataFieldName, + } + if rec.key and rec.key ~= "" then points[rec.key] = rec end + if rec.name and rec.name ~= "" then points[rec.name] = rec end + end + end + return points +end + +-- Tesla-shaped vocabulary Core ranks in telemetry.VehicleConnectedRank. +local function map_charging_state(points) + local plug = lookup(points, PLUG_IDS) + local plugged = plug and lower(plug.value) == "connected" or false + + local cs = lookup(points, CHARGE_STATE_IDS) + if cs then + local u = upper(cs.value) + local l = lower(cs.value) + if string.find(u, "CHARGING_HV", 1, true) + or l == "charging" + or l == "conservationcharging" + or u == "CHARGE_STATE_CONSERVATION_CHARGING" then + return "Charging" + end + end + + local sc = lookup(points, SCENARIO_IDS) + if sc then + local u = upper(sc.value) + if string.sub(u, -7) == "_ACTIVE" then + return "Charging" + end + if string.sub(u, -9) == "_FINISHED" then + if plugged then return "Complete" end + return "Disconnected" + end + end + + if plug then + if plugged then return "Stopped" end + return "Disconnected" + end + return nil +end + +local function parse_dataset(blob) + local json, err = zip_extract_json(blob) + if not json then return nil, err end + local decoded, derr = safe_json_decode(json) + if not decoded then return nil, derr end + local rows = decoded.Data or decoded.data + return index_points(rows) +end + +local function dataset_name(entry) + if type(entry) ~= "table" then return nil end + return entry.name or entry.Name +end + +local function content_datasets(list) + local out = {} + if type(list) ~= "table" then return out end + local rows = list + if list.files then rows = list.files end + if list.Files then rows = list.Files end + for i = 1, #rows do + local name = dataset_name(rows[i]) + if type(name) == "string" + and not string.find(string.lower(name), "_no_content_found.zip", 1, true) then + out[#out + 1] = rows[i] + end + end + return out +end + +local function newest_dataset(list) + local content = content_datasets(list) + if #content == 0 then return nil end + return content[#content] +end + +local function emit_reading(soc, limit, state, ttf, fresh, stale) + host.emit("vehicle", { + soc = soc, + charge_limit_pct = limit, + charging_state = state, + time_to_full_min = ttf, + stale = stale, + soc_fresh = fresh, + }) +end + +local function emit_last() + if last.soc == nil then return end + local age = host.millis() - last.ts_ms + if age > STALE_AFTER_MS then + return + end + emit_reading( + last.soc, last.charge_limit, last.charging_state, last.time_to_full, + false, age > (STALE_AFTER_MS / 2)) +end + +local function remember(soc, limit, state, ttf) + last.soc = soc + last.charge_limit = limit + last.charging_state = state + last.time_to_full = ttf + last.ts_ms = host.millis() +end + +local function resolve_brand(raw) + if type(raw) ~= "string" or raw == "" then return nil, "brand required" end + local key = string.lower(raw) + key = key:gsub("š", "s"):gsub("%s+", "_") + if key == "porsche" then + return nil, "Porsche is not on the VW Group EU Data Act portal" + end + local name = BRANDS[key] + if not name then + return nil, "unknown brand (volkswagen|audi|skoda|seat|cupra)" + end + return name +end + +local function normalize_cookie(raw) + if type(raw) ~= "string" then return nil end + local s = raw:gsub("^%s+", ""):gsub("%s+$", "") + s = s:gsub("^[Cc]ookie:%s*", "") + if s == "" then return nil end + return s +end + +local function ensure_request_id() + if request_id and request_id ~= "" then return request_id end + local body, err = api_get( + "/proxy_api/euda-apim/datarequest/vehicles/" .. vin .. "/metadata/partial") + if err then return nil, err end + local data, derr = safe_json_decode(body) + if not data then return nil, derr end + local id = data.Identifier or data.identifier + if type(id) ~= "string" or id == "" then + return nil, "no data request configured for vehicle" + end + request_id = id + return request_id +end + +function driver_init(config) + if not config then + host.log("error", "vag: config required (vin + brand + cookie)") + return + end + local err + brand_name, err = resolve_brand(config.brand) + if not brand_name then + host.log("error", "vag: " .. tostring(err)) + return + end + vin = config.vin + if type(vin) == "string" then + vin = vin:gsub("%s+", ""):upper() + end + if type(vin) ~= "string" or vin == "" then + host.log("error", "vag: `vin` required") + return + end + cookie = normalize_cookie(config.cookie or config.session_cookie) + if not cookie then + host.log("error", "vag: `cookie` required — paste the portal Cookie header after enabling the 15-minute All Data request. Charging does not need this cloud.") + return + end + host.set_make(brand_name) + host.set_sn(vin) + if host.set_watchdog_timeout_s then + host.set_watchdog_timeout_s(WATCHDOG_TIMEOUT_S) + end + host.set_poll_interval(500) + host.log("info", "vag: telemetry-only EU Data Act driver brand=" .. + brand_name .. " vin=" .. vin) +end + +function driver_poll() + if not vin or not cookie or not brand_name then + return 30000 + end + host.set_poll_interval(POLL_INTERVAL_MS) + + local id, iderr = ensure_request_id() + if not id then + local es = tostring(iderr) + if es:match("HTTP 401") or es:match("HTTP 403") then + host.log("warn", "vag: portal session expired — refresh config.cookie. Charging continues without vehicle cloud.") + elseif es:match("HTTP 404") or es:match("no data request") then + host.log("warn", "vag: no continuous data request — enable All Data / 15 min on the EU Data Act portal") + else + host.log("warn", "vag: metadata failed: " .. es) + end + emit_last() + return POLL_INTERVAL_MS + end + + local list_body, lerr = api_get( + "/proxy_api/euda-apim/datadelivery/vehicles/" .. vin .. "/" .. id .. "/list", + { type = "partial" }) + if lerr then + local es = tostring(lerr) + if es:match("HTTP 401") or es:match("HTTP 403") then + host.log("warn", "vag: portal session expired — refresh config.cookie. Charging continues without vehicle cloud.") + request_id = nil + elseif es:match("HTTP 404") then + host.log("debug", "vag: no dataset files yet") + else + host.log("warn", "vag: list failed: " .. es) + end + emit_last() + return POLL_INTERVAL_MS + end + + local list, derr = safe_json_decode(list_body) + if not list then + host.log("warn", "vag: list json failed: " .. tostring(derr)) + emit_last() + return POLL_INTERVAL_MS + end + + local ds = newest_dataset(list) + if not ds then + emit_last() + return POLL_INTERVAL_MS + end + local name = dataset_name(ds) + if last_file == name and last.soc ~= nil then + emit_last() + return POLL_INTERVAL_MS + end + + local zip, zerr = api_get( + "/proxy_api/euda-apim/datadelivery/vehicles/" .. vin .. "/" .. id .. "/download", + { type = "partial", filename = name }) + if zerr then + host.log("warn", "vag: download failed: " .. tostring(zerr)) + emit_last() + return POLL_INTERVAL_MS + end + + local points, perr = parse_dataset(zip) + if not points then + host.log("warn", "vag: dataset decode failed: " .. tostring(perr)) + emit_last() + return POLL_INTERVAL_MS + end + + local soc_p = lookup(points, SOC_IDS) + local soc = soc_p and num(soc_p.value) or nil + if soc == nil then + host.log("debug", "vag: dataset had no SoC field") + emit_last() + return POLL_INTERVAL_MS + end + + local limit_ids = LIMIT_IDS + local bcam = lookup(points, BCAM_ACTIVE_IDS) + if bcam and bcam.value == "BCAM_ACTIVATION_ACTIVATED" then + limit_ids = { + LIMIT_IDS[1], LIMIT_IDS[2], + BCAM_THRESHOLD_IDS[1], + } + end + local limit_p = lookup(points, limit_ids) + local limit = limit_p and num(limit_p.value) or nil + local ttf_p = lookup(points, TTF_IDS) + local ttf = ttf_p and num(ttf_p.value) or nil + if ttf == 65535 then ttf = nil end + local state = map_charging_state(points) + + last_file = name + remember(soc, limit, state, ttf) + host.log("info", "vag: emit soc=" .. tostring(soc) .. + " limit=" .. tostring(limit) .. + " state=" .. tostring(state) .. + " file=" .. tostring(name)) + emit_reading(soc, limit, state, ttf, true, false) + return POLL_INTERVAL_MS +end + +function driver_cleanup() + vin = nil + brand_name = nil + cookie = nil + request_id = nil + last_file = nil + last.soc = nil + last.charge_limit = nil + last.charging_state = nil + last.time_to_full = nil + last.ts_ms = 0 +end diff --git a/drivers/tests/lua_harness/test_vag_vehicle.lua b/drivers/tests/lua_harness/test_vag_vehicle.lua new file mode 100644 index 0000000..405ce06 --- /dev/null +++ b/drivers/tests/lua_harness/test_vag_vehicle.lua @@ -0,0 +1,129 @@ +-- Telemetry-only VAG / EU Data Act vehicle driver. +-- Args: stored.zip deflated.zip + +dofile("drivers/tests/lua_harness/host_mock.lua") + +local stored_path, deflated_path = arg[1], arg[2] +assert(stored_path and deflated_path, "usage: test_vag_vehicle.lua stored.zip deflated.zip") + +local function read_bin(path) + local f = assert(io.open(path, "rb")) + local data = f:read("*a") + f:close() + return data +end + +local stored_zip = read_bin(stored_path) +local deflated_zip = read_bin(deflated_path) +assert(#stored_zip > 0 and #deflated_zip > 0, "empty zip fixture") + +local VIN = "WVWZZZTESTVIN0001" +local REQ = "req-continuous-1" +local FILE = "2026-09-26T07-00-00_partial.zip" +local META = "/datarequest/vehicles/" .. VIN .. "/metadata/partial" +local LIST = "/datadelivery/vehicles/" .. VIN .. "/" .. REQ .. "/list" +local DL = "/datadelivery/vehicles/" .. VIN .. "/" .. REQ .. "/download" + +local orig_http_get = host.http_get +function host.http_get(url, headers) + if host._http_errors then + for pattern, err in pairs(host._http_errors) do + if string.find(url, pattern, 1, true) then + return nil, err + end + end + end + return orig_http_get(url, headers) +end + +local function boot(cfg) + host.reset() + host._http_errors = {} + dofile("drivers/lua/vag_vehicle.lua") + driver_init(cfg or { + vin = VIN, + brand = "volkswagen", + cookie = "SESSION=test; Path=/", + }) +end + +local function prime(zip_body, file_name) + host._http_responses[META] = host.json_encode({ Identifier = REQ }) + host._http_responses[LIST] = host.json_encode({ + { name = file_name or FILE, createdOn = "2026-09-26T07:00:00Z" }, + }) + host._http_responses[DL] = zip_body +end + +boot({ brand = "porsche", vin = VIN, cookie = "x=1" }) +assert(host._make == nil, "Porsche must not bind identity") +driver_poll() +assert(not host._emitted.vehicle, "Porsche must not emit") + +boot({ brand = "audi", vin = VIN }) +assert(host._sn == nil, "missing cookie still set serial") +driver_poll() +assert(not host._emitted.vehicle, "missing cookie must not emit") + +boot() +assert(host._make == "Volkswagen", "set_make from brand") +assert(host._sn == VIN, "set_sn from VIN") + +prime(stored_zip) +driver_poll() +local rows = host._emitted.vehicle +assert(rows and #rows == 1, "stored zip should emit once") +local first = rows[1] +assert(first.soc == 63, "soc from battery_level_HV.value, got " .. tostring(first.soc)) +assert(first.charge_limit_pct == 80, "target soc") +assert(first.charging_state == "Charging", "charging_state mapped") +assert(first.time_to_full_min == 42, "remaining_charging_time") +assert(first.stale == false, "fresh dataset is not stale") +assert(first.soc_fresh == true, "new file is a fresh observation") + +local before = #rows +driver_poll() +assert(#host._emitted.vehicle == before + 1, "same file still reports last reading") +local replay = host._emitted.vehicle[#host._emitted.vehicle] +assert(replay.soc == 63, "replay keeps soc") +assert(replay.soc_fresh == false, "same zip is not a new observation") + +boot() +prime(deflated_zip) +driver_poll() +rows = host._emitted.vehicle +assert(rows and #rows == 1, "deflated zip should emit") +assert(rows[1].soc == 63, "deflate extract soc") +assert(rows[1].charging_state == "Charging", "deflate extract state") + +boot() +host._http_errors[META] = "HTTP 401: unauthorized" +driver_poll() +assert(not host._emitted.vehicle, "401 before first reading emits nothing") + +boot() +prime(stored_zip) +driver_poll() +assert(#host._emitted.vehicle == 1, "seed reading") +host._http_errors[LIST] = "HTTP 403: forbidden" +driver_poll() +assert(#host._emitted.vehicle == 2, "expired session may replay last") +assert(host._emitted.vehicle[2].soc_fresh == false, "401/403 replay is cached") + +boot() +prime(stored_zip) +driver_poll() +host._millis_counter = host._millis_counter + 2700001 +host._http_errors[LIST] = "HTTP 404: no files" +driver_poll() +assert(#host._emitted.vehicle == 1, "stale last reading must stop emitting") + +boot() +host._http_responses[META] = host.json_encode({ Identifier = REQ }) +host._http_responses[LIST] = host.json_encode({ + { name = "2026-09-26T06-00-00_no_content_found.zip", createdOn = "2026-09-26T06:00:00Z" }, +}) +driver_poll() +assert(not host._emitted.vehicle, "asleep placeholder must not invent SoC") + +print("vag_vehicle: ok") diff --git a/drivers/tests/test_vag_vehicle.py b/drivers/tests/test_vag_vehicle.py new file mode 100644 index 0000000..a97ba90 --- /dev/null +++ b/drivers/tests/test_vag_vehicle.py @@ -0,0 +1,43 @@ +"""VAG EU Data Act driver: identity, zip decode, freshness, 401.""" +from pathlib import Path +import io +import subprocess +import zipfile + + +ROOT = Path(__file__).resolve().parents[2] + +DATASET = """{"vin":"WVWZZZTESTVIN0001","Data":[ + {"key":"162c2a75-edf4-3990-b8ed-7c600b3dbc40","dataFieldName":"battery_level_HV.value","value":"63","timestampUtc":"2026-09-26T07:00:00Z"}, + {"key":"76acaa98-37ef-3466-b013-21c77ed343ae","dataFieldName":"settings.target_soc","value":"80","timestampUtc":"2026-09-26T07:00:00Z"}, + {"key":"9da735bb-c5d5-39f8-bf53-0fa2a367aa8f","dataFieldName":"charging_state","value":"charging","timestampUtc":"2026-09-26T07:00:00Z"}, + {"key":"c111830c-f959-30d2-859a-ea996190d864","dataFieldName":"plug_state","value":"connected","timestampUtc":"2026-09-26T07:00:00Z"}, + {"key":"cf28f7d9-6201-30b8-82e5-a461968d30dc","dataFieldName":"remaining_charging_time","value":"42","timestampUtc":"2026-09-26T07:00:00Z"} +]}""" + + +def _zip(method: int) -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", compression=method) as zf: + zf.writestr("dataset.json", DATASET) + return buf.getvalue() + + +def test_vag_vehicle_dataset_and_freshness(tmp_path: Path) -> None: + stored = tmp_path / "stored.zip" + deflated = tmp_path / "deflated.zip" + stored.write_bytes(_zip(zipfile.ZIP_STORED)) + deflated.write_bytes(_zip(zipfile.ZIP_DEFLATED)) + result = subprocess.run( + [ + str(ROOT / "lua55"), + "drivers/tests/lua_harness/test_vag_vehicle.lua", + str(stored), + str(deflated), + ], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/index.yaml b/index.yaml index baeff0e..72ae5e4 100644 --- a/index.yaml +++ b/index.yaml @@ -727,6 +727,16 @@ drivers: control: false size_bytes: 12472 sha256: "3cf10b93755fa8b840375f53343e91b5efa98ddffe119a5679b43ee4bfb61c88" + - name: "vag_vehicle" + version: "0.1.0" + tier: community + protocol: http + connectivity: cloud + setup: [vendor_portal] + ders: [vehicle] + control: false + size_bytes: 23703 + sha256: "2781120682e9f8b4c864d317f3c62fb259375073a91e828bf25010e0d3fd7726" - name: "varta" version: "1.1.1" tier: community diff --git a/manifests/vag_vehicle.yaml b/manifests/vag_vehicle.yaml new file mode 100644 index 0000000..4b98d46 --- /dev/null +++ b/manifests/vag_vehicle.yaml @@ -0,0 +1,29 @@ +name: "vag_vehicle" +version: "0.1.0" +tier: community +author: "Sourceful Labs AB" +protocol: http +connectivity: cloud +setup: [vendor_portal] +ders: [vehicle] +control: false +tested_devices: + - manufacturer: "Volkswagen Group" + model_family: "VAG Vehicle (EU Data Act)" + variants: [ID.3, ID.4, Enyaq, Q4 e-tron] + regions: [EU] + firmware_versions: "" + notes: "Read-only SoC and charge state from the VW Group EU Data Act portal. We Connect third-party APIs are blocked. Owner must enable a continuous 15-minute All Data request and paste a portal session cookie. Not live BMS; charging does not need this cloud. Porsche is not on this portal. Not yet exercised on a live FTW site." + min_driver_version: "0.1.0" +upstream_docs: + - url: "https://eu-data-act.drivesomethinggreater.com/" + title: "VW Group EU Data Act portal" + kind: api_docs + url_stability: stable +min_host_version: "2.0.0" +size_bytes: 23703 +dkb_id: "vag_vehicle" +sha256: "2781120682e9f8b4c864d317f3c62fb259375073a91e828bf25010e0d3fd7726" +signature: "" + +bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index cbbac3b..3553dc2 100644 --- a/support-status.json +++ b/support-status.json @@ -1659,6 +1659,27 @@ } } }, + { + "catalog_source": true, + "catalog_version": "0.1.0", + "driver_id": "vag_vehicle", + "targets": { + "blixt-l1": { + "hil": "not_recorded", + "historical_signed_beta_version": null, + "legacy_parity": "not_assessed", + "note": "", + "target_conformance": "not_assessed" + }, + "ftw-core": { + "hil": "not_recorded", + "historical_signed_beta_version": null, + "legacy_parity": "not_assessed", + "note": "", + "target_conformance": "not_assessed" + } + } + }, { "catalog_source": true, "catalog_version": "1.1.1", From 16bb31cbd85a512b7fd1614152bd73e2ebd8236a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 26 Sep 2026 07:11:27 +0000 Subject: [PATCH 2/3] fix(vag_vehicle): bind identity before cookie and pick newest dataset Set make/serial from brand+VIN even when the portal cookie is missing so the car still appears. Sort portal files by createdOn instead of array order. No wake or charge_start. Signed-off-by: Cursor Agent --- drivers/lua/vag_vehicle.lua | 28 +++++++++++++++++-- .../tests/lua_harness/test_vag_vehicle.lua | 21 +++++++++++++- index.yaml | 4 +-- manifests/vag_vehicle.yaml | 4 +-- 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/drivers/lua/vag_vehicle.lua b/drivers/lua/vag_vehicle.lua index 389591a..6c1b0f8 100644 --- a/drivers/lua/vag_vehicle.lua +++ b/drivers/lua/vag_vehicle.lua @@ -577,10 +577,26 @@ local function content_datasets(list) return out end +local function dataset_time(entry) + if type(entry) ~= "table" then return "" end + local t = entry.createdOn or entry.CreatedOn or entry.created_on + if type(t) == "string" and t ~= "" then return t end + return dataset_name(entry) or "" +end + +-- Portal list order is not a contract. evcc sorts by createdOn; we do the +-- same and fall back to the file name (ISO-ish prefix). local function newest_dataset(list) local content = content_datasets(list) if #content == 0 then return nil end - return content[#content] + local best, best_t = content[1], dataset_time(content[1]) + for i = 2, #content do + local t = dataset_time(content[i]) + if t >= best_t then + best, best_t = content[i], t + end + end + return best end local function emit_reading(soc, limit, state, ttf, fresh, stale) @@ -669,13 +685,15 @@ function driver_init(config) host.log("error", "vag: `vin` required") return end + -- Bind identity even when the session cookie is missing so the site + -- still sees the car. Cloud is not a charging prerequisite. + host.set_make(brand_name) + host.set_sn(vin) cookie = normalize_cookie(config.cookie or config.session_cookie) if not cookie then host.log("error", "vag: `cookie` required — paste the portal Cookie header after enabling the 15-minute All Data request. Charging does not need this cloud.") return end - host.set_make(brand_name) - host.set_sn(vin) if host.set_watchdog_timeout_s then host.set_watchdog_timeout_s(WATCHDOG_TIMEOUT_S) end @@ -788,6 +806,10 @@ function driver_poll() return POLL_INTERVAL_MS end +function driver_default_mode() + -- Telemetry only: no output to reset. Charging does not use this cloud. +end + function driver_cleanup() vin = nil brand_name = nil diff --git a/drivers/tests/lua_harness/test_vag_vehicle.lua b/drivers/tests/lua_harness/test_vag_vehicle.lua index 405ce06..71a6274 100644 --- a/drivers/tests/lua_harness/test_vag_vehicle.lua +++ b/drivers/tests/lua_harness/test_vag_vehicle.lua @@ -33,12 +33,19 @@ function host.http_get(url, headers) end end end + if host._download_only and string.find(url, DL, 1, true) then + local fn = headers and headers.filename + if fn ~= host._download_only then + return nil, "HTTP 404: wrong file " .. tostring(fn) + end + end return orig_http_get(url, headers) end local function boot(cfg) host.reset() host._http_errors = {} + host._download_only = nil dofile("drivers/lua/vag_vehicle.lua") driver_init(cfg or { vin = VIN, @@ -61,7 +68,7 @@ driver_poll() assert(not host._emitted.vehicle, "Porsche must not emit") boot({ brand = "audi", vin = VIN }) -assert(host._sn == nil, "missing cookie still set serial") +assert(host._make == "Audi" and host._sn == VIN, "identity binds before cookie") driver_poll() assert(not host._emitted.vehicle, "missing cookie must not emit") @@ -126,4 +133,16 @@ host._http_responses[LIST] = host.json_encode({ driver_poll() assert(not host._emitted.vehicle, "asleep placeholder must not invent SoC") +boot() +prime(stored_zip) +-- Older file last: a naive "last array element" pick would request it and fail. +host._download_only = FILE +host._http_responses[LIST] = host.json_encode({ + { name = FILE, createdOn = "2026-09-26T07:00:00Z" }, + { name = "2026-09-26T06-00-00_partial.zip", createdOn = "2026-09-26T06:00:00Z" }, +}) +driver_poll() +assert(host._emitted.vehicle and host._emitted.vehicle[1].soc == 63, + "newest createdOn wins even when it is not last") + print("vag_vehicle: ok") diff --git a/index.yaml b/index.yaml index 72ae5e4..471972c 100644 --- a/index.yaml +++ b/index.yaml @@ -735,8 +735,8 @@ drivers: setup: [vendor_portal] ders: [vehicle] control: false - size_bytes: 23703 - sha256: "2781120682e9f8b4c864d317f3c62fb259375073a91e828bf25010e0d3fd7726" + size_bytes: 24508 + sha256: "2f01b85b115acf754b90553ec580b7a8c543c01a15feb65c464e4e41b5a79bf5" - name: "varta" version: "1.1.1" tier: community diff --git a/manifests/vag_vehicle.yaml b/manifests/vag_vehicle.yaml index 4b98d46..52a60af 100644 --- a/manifests/vag_vehicle.yaml +++ b/manifests/vag_vehicle.yaml @@ -21,9 +21,9 @@ upstream_docs: kind: api_docs url_stability: stable min_host_version: "2.0.0" -size_bytes: 23703 +size_bytes: 24508 dkb_id: "vag_vehicle" -sha256: "2781120682e9f8b4c864d317f3c62fb259375073a91e828bf25010e0d3fd7726" +sha256: "2f01b85b115acf754b90553ec580b7a8c543c01a15feb65c464e4e41b5a79bf5" signature: "" bytecode_sha256: "" From b4e65b8d894ad6add90e7c2dde64e433d9c11b3e Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 26 Sep 2026 13:00:03 +0200 Subject: [PATCH 3/3] fix(vag_vehicle): honest first reading, bounded unzip, streamed zips The newest file at the first listing after start was reported as a fresh reading, though it can be hours old when the car has slept. The host has no wall clock, so that file is now reported stale until a newer one arrives. Unzipping kept one table slot per output byte. The newest 32 KiB now stay per byte for back-references and older text is flushed into chunks, which cut allocation for 1 MB of JSON from 9 MB to 3 MB, and a dataset over 4 MiB is refused. Entries from streaming writers, which put their sizes in a data descriptor after the data, now decode too. A file without SoC was downloaded again on every poll; each file is now read once. The file list is fetched every 5 minutes instead of 2, and every poll sets the interval, since the host ignores driver_poll's return value. As in evcc, the newest point among candidate fields wins. The data-point keys were checked against VW's data dictionary V5.0, and upstream_docs now watches that dictionary instead of the portal's web app. Signed-off-by: Fredrik Ahlgren Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 +- devices.yaml | 2 +- drivers/lua/vag_vehicle.lua | 160 +++++++++---- .../tests/lua_harness/test_vag_vehicle.lua | 212 +++++++++++++----- drivers/tests/test_vag_vehicle.py | 65 +++++- index.yaml | 4 +- manifests/vag_vehicle.yaml | 12 +- 7 files changed, 336 insertions(+), 129 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c9f669..163e4e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,7 @@ ## vag_vehicle 0.1.0 -Read-only VW / Audi / Škoda / SEAT / Cupra telemetry from the VW Group EU -Data Act portal. We Connect third-party APIs are blocked; this is the remaining -owner door. The driver emits `DerVehicle` (SoC, charge limit, charging_state, -time-to-full, stale, soc_fresh), binds identity with `set_make` / `set_sn` -(VIN), ages the last dataset and stops emitting when it is stale. Cloud access -is not a charging prerequisite. The owner enables a continuous 15-minute All -Data request on the portal and pastes a session Cookie header — the Lua host -has no cookie jar and cannot complete the portal's OIDC login. Porsche, wake -and charge_start are out of scope. +Read-only VW, Audi, Škoda, SEAT and Cupra telemetry from the VW Group EU Data Act portal, the one owner door left now that We Connect's third-party APIs are closed. The owner orders a continuous 15-minute All Data delivery on the portal and pastes the Cookie header of a logged-in portal session: the Lua host has no cookie jar and cannot complete the portal's OIDC login, so the driver stops when that session ends. Every 5 minutes it lists the delivered files, downloads the newest one with content, unzips it in Lua (streamed zip entries included, 4 MiB cap) and emits `DerVehicle`: SoC, charge limit, charging state and time to full. Data keys follow VW's data dictionary V5.0. The newest file at start has an unknown age, so it is reported stale until a newer file arrives. Portal data are about 15 minutes old, so Core will mostly show this SoC rather than steer by it. Cloud access is not a charging prerequisite. Porsche, wake and charge start are out of scope. Not yet run against the portal or a car. ## esphome_dsmr 1.0.6 diff --git a/devices.yaml b/devices.yaml index ec19d72..79b9a2d 100644 --- a/devices.yaml +++ b/devices.yaml @@ -2033,7 +2033,7 @@ manufacturers: ders: [vehicle] control: false firmware_versions: "" - notes: "Read-only SoC and charge state from the VW Group EU Data Act portal. We Connect third-party APIs are blocked. Owner must enable a continuous 15-minute All Data request and paste a portal session cookie. Not live BMS; charging does not need this cloud. Porsche is not on this portal. Not yet exercised on a live FTW site." + notes: "Read-only SoC and charge state from the VW Group EU Data Act portal. We Connect third-party APIs are blocked. Owner must enable a continuous 15-minute All Data request and paste a portal session cookie. Not live BMS; charging does not need this cloud. Porsche is not on this portal. Not yet run against the portal or a car." - name: "Wallbox" model_families: - name: "Commander/Pulsar" diff --git a/drivers/lua/vag_vehicle.lua b/drivers/lua/vag_vehicle.lua index 6c1b0f8..9615ae5 100644 --- a/drivers/lua/vag_vehicle.lua +++ b/drivers/lua/vag_vehicle.lua @@ -40,9 +40,11 @@ -- brand: volkswagen # audi | skoda | seat | cupra -- cookie: "name=value; ..." # masked via config_secrets -- --- Field names and GUIDs follow the portal data dictionary / evcc's --- drivesomethinggreater mapping (vehicle/vw/eudataact). Look up by --- GUID first: several points share the name battery_state_report.soc. +-- Keys (GUIDs) and names come from VW's data dictionary ("DataDictionary +-- V5.0, Continuous Data"), as parsed in evcc's vehicle/vw/eudataact, which +-- reads the same portal. As there, the newest point among the candidates +-- wins. battery_state_report.soc is looked up by key only: four points +-- share that name. DRIVER = { host_api_min = 1, @@ -66,7 +68,9 @@ DRIVER = { PROTOCOL = "http" local BASE_URL = "https://eu-data-act.drivesomethinggreater.com" -local POLL_INTERVAL_MS = 120000 +-- The portal writes a file about every 15 minutes; asking every 5 is +-- enough. The host keeps the interval last set with set_poll_interval. +local POLL_INTERVAL_MS = 300000 local WATCHDOG_TIMEOUT_S = 600 -- Portal cadence is ~15 min. Keep a last reading visible a little -- longer than that, then stop so Core's watchdog sees the gap. @@ -85,7 +89,8 @@ local BRANDS = { -- SoC / limit / state / remaining-time ids from the Data Act dictionary. -- Name fallbacks stay for datasets that omit the GUID. local SOC_IDS = { - "162c2a75-edf4-3990-b8ed-7c600b3dbc40", -- battery_level_HV.value + "162c2a75-edf4-3990-b8ed-7c600b3dbc40", -- battery_level_HV.battery_level_HV.value + "ac1108b1-b8cc-3db9-a663-03d387e42223", -- battery_level_HV.value "ae0294b4-1286-3e98-a818-1485b8d88430", -- state_of_charge "f89ed652-d104-3fa6-b7e2-ab7543309e7b", -- hv_soc "506cb83e-f99f-3af3-bbeb-0429b69a78d9", -- battery_state_report.soc (ID.3) @@ -129,12 +134,16 @@ local brand_name = nil local cookie = nil local request_id = nil local last_file = nil +-- Newest content file at the first listing after start (false: there was +-- none). The host has no wall clock, so its age is unknown. +local baseline_file = nil local last = { ts_ms = 0, soc = nil, charge_limit = nil, charging_state = nil, time_to_full = nil, + known_age = false, } --------------------------------------------------------------------------- @@ -155,10 +164,34 @@ local function u32le(s, i) return lo + hi * 65536 end +-- A dataset larger than this is refused rather than inflated: the charging +-- fields need far less, and every driver shares the host's memory. +local MAX_JSON_BYTES = 4194304 +-- DEFLATE back-references reach at most this far back. +local WINDOW = 32768 + local function inflate_raw(src) local pos = 1 local bitbuf, bitcnt = 0, 0 - local out = {} + -- The newest bytes stay one per slot for back-references. Older text is + -- flushed into chunks, so the table does not grow with the dataset. + local out, n = {}, 0 + local chunks, flushed = {}, 0 + + local function flush() + local keep = n - WINDOW + chunks[#chunks + 1] = table.concat(out, "", 1, keep) + flushed = flushed + keep + for i = 1, WINDOW do out[i] = out[keep + i] end + for i = WINDOW + 1, n do out[i] = nil end + n = WINDOW + end + + -- Called between symbols, never inside a copy, so indices stay valid. + local function room() + if n >= 4 * WINDOW then flush() end + return flushed + n <= MAX_JSON_BYTES + end local function pull_byte() if pos > #src then return nil end @@ -322,10 +355,12 @@ local function inflate_raw(src) local function inflate_block(lit_tree, lit_max, dist_tree, dist_max) while true do + if not room() then return nil, "dataset too large" end local sym = decode(lit_tree, lit_max) if sym == nil then return nil end if sym < 256 then - out[#out + 1] = string.char(sym) + n = n + 1 + out[n] = string.char(sym) elseif sym == 256 then return true else @@ -339,10 +374,11 @@ local function inflate_raw(src) extra = bits(DIST_EXTRA[dsym + 1]) if extra == nil then return nil end local dist = DIST_BASE[dsym + 1] + extra - local start = #out - dist + 1 + local start = n - dist + 1 if start < 1 then return nil end - for i = 1, length do - out[#out + 1] = out[start + i - 1] + for i = 0, length - 1 do + n = n + 1 + out[n] = out[start + i] end end end @@ -362,9 +398,11 @@ local function inflate_raw(src) if pos + len - 1 > #src then return nil end -- One byte per slot so later length/distance copies stay correct. for j = pos, pos + len - 1 do - out[#out + 1] = string.sub(src, j, j) + n = n + 1 + out[n] = string.sub(src, j, j) end pos = pos + len + if not room() then return nil, "dataset too large" end elseif btype == 1 or btype == 2 then local lit, dist if btype == 1 then @@ -375,14 +413,16 @@ local function inflate_raw(src) end local lit_tree, lit_max = build_tree(lit) local dist_tree, dist_max = build_tree(dist) - if not inflate_block(lit_tree, lit_max, dist_tree, dist_max) then - return nil + local ok, why = inflate_block(lit_tree, lit_max, dist_tree, dist_max) + if not ok then + return nil, why end else return nil end if bfinal == 1 then - return table.concat(out) + chunks[#chunks + 1] = table.concat(out, "", 1, n) + return table.concat(chunks) end end end @@ -393,6 +433,7 @@ local function zip_extract_json(blob) end local first = blob:match("^%s*(.)") if first == "{" or first == "[" then + if #blob > MAX_JSON_BYTES then return nil, "dataset too large" end return blob end local i = 1 @@ -400,32 +441,52 @@ local function zip_extract_json(blob) if string.sub(blob, i, i + 3) ~= "PK\003\004" then i = i + 1 else + local flags = u16le(blob, i + 6) local method = u16le(blob, i + 8) local comp_size = u32le(blob, i + 18) local name_len = u16le(blob, i + 26) local extra_len = u16le(blob, i + 28) - if not method or not comp_size or not name_len or not extra_len then + if not flags or not method or not comp_size or not name_len or not extra_len then return nil, "truncated zip header" end local name_at = i + 30 local data_at = name_at + name_len + extra_len local name = string.sub(blob, name_at, name_at + name_len - 1) - local payload = string.sub(blob, data_at, data_at + comp_size - 1) - if #payload < comp_size then - return nil, "truncated zip payload" - end + -- Flag bit 3: the sizes follow the data, and this header says 0. + -- Streaming writers such as Java's ZipOutputStream do that. DEFLATE + -- marks its own end, so such an entry inflates from here on. + local streamed = math.floor(flags / 8) % 2 == 1 and comp_size == 0 if name:lower():match("%.json$") then - if method == 0 then - return payload - elseif method == 8 then - local raw = inflate_raw(payload) - if not raw then return nil, "deflate failed" end + if method == 8 then + local payload + if streamed then + payload = string.sub(blob, data_at) + else + payload = string.sub(blob, data_at, data_at + comp_size - 1) + if #payload < comp_size then + return nil, "truncated zip payload" + end + end + local raw, ierr = inflate_raw(payload) + if not raw then return nil, ierr or "deflate failed" end return raw + elseif method == 0 and not streamed then + if comp_size > MAX_JSON_BYTES then return nil, "dataset too large" end + local payload = string.sub(blob, data_at, data_at + comp_size - 1) + if #payload < comp_size then + return nil, "truncated zip payload" + end + return payload else - return nil, "unsupported zip method " .. tostring(method) + return nil, "unsupported zip entry (method " .. tostring(method) .. ")" end end - i = data_at + comp_size + -- A streamed entry has no size to skip by; scan for the next header. + if streamed then + i = data_at + else + i = data_at + comp_size + end end end return nil, "no json document in dataset" @@ -466,15 +527,18 @@ local function api_get(path, extra) return safe_http_get(BASE_URL .. path, auth_headers(extra)) end +-- The newest point among the candidates wins; on a tie, the earlier +-- candidate. Points are numbered in dataset order. 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 ~= "" then - return p + if p and p.value ~= nil and p.value ~= "" and (best == nil or p.seq > best.seq) then + best = p end end - return nil + return best end local function num(value) @@ -503,6 +567,7 @@ local function index_points(data) value = tostring(dp.value), key = dp.key, name = dp.dataFieldName or dp.DataFieldName, + 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 @@ -618,14 +683,15 @@ local function emit_last() end emit_reading( last.soc, last.charge_limit, last.charging_state, last.time_to_full, - false, age > (STALE_AFTER_MS / 2)) + false, not last.known_age or age > (STALE_AFTER_MS / 2)) end -local function remember(soc, limit, state, ttf) +local function remember(soc, limit, state, ttf, known_age) last.soc = soc last.charge_limit = limit last.charging_state = state last.time_to_full = ttf + last.known_age = known_age last.ts_ms = host.millis() end @@ -703,10 +769,10 @@ function driver_init(config) end function driver_poll() + host.set_poll_interval(POLL_INTERVAL_MS) if not vin or not cookie or not brand_name then - return 30000 + return POLL_INTERVAL_MS end - host.set_poll_interval(POLL_INTERVAL_MS) local id, iderr = ensure_request_id() if not id then @@ -747,12 +813,13 @@ function driver_poll() end local ds = newest_dataset(list) - if not ds then - emit_last() - return POLL_INTERVAL_MS + local name = ds and dataset_name(ds) or nil + -- The newest file at the first listing can be hours old if the car has + -- slept since. Only a file that appears after it is a new observation. + if baseline_file == nil then + baseline_file = name or false end - local name = dataset_name(ds) - if last_file == name and last.soc ~= nil then + if not ds or last_file == name then emit_last() return POLL_INTERVAL_MS end @@ -765,6 +832,8 @@ function driver_poll() emit_last() return POLL_INTERVAL_MS end + -- Read each file once, even one that turns out useless. + last_file = name local points, perr = parse_dataset(zip) if not points then @@ -796,13 +865,18 @@ function driver_poll() if ttf == 65535 then ttf = nil end local state = map_charging_state(points) - last_file = name - remember(soc, limit, state, ttf) - host.log("info", "vag: emit soc=" .. tostring(soc) .. + local known_age = name ~= baseline_file + remember(soc, limit, state, ttf, known_age) + host.log("info", "vag: " .. (known_age and "emit" or "first file since start, age unknown, stale:") .. + " soc=" .. tostring(soc) .. " limit=" .. tostring(limit) .. " state=" .. tostring(state) .. " file=" .. tostring(name)) - emit_reading(soc, limit, state, ttf, true, false) + if known_age then + emit_reading(soc, limit, state, ttf, true, false) + else + emit_last() + end return POLL_INTERVAL_MS end @@ -816,9 +890,11 @@ function driver_cleanup() cookie = nil request_id = nil last_file = nil + baseline_file = nil last.soc = nil last.charge_limit = nil last.charging_state = nil last.time_to_full = nil + last.known_age = false last.ts_ms = 0 end diff --git a/drivers/tests/lua_harness/test_vag_vehicle.lua b/drivers/tests/lua_harness/test_vag_vehicle.lua index 71a6274..e7387ae 100644 --- a/drivers/tests/lua_harness/test_vag_vehicle.lua +++ b/drivers/tests/lua_harness/test_vag_vehicle.lua @@ -1,10 +1,12 @@ -- Telemetry-only VAG / EU Data Act vehicle driver. --- Args: stored.zip deflated.zip +-- Args: stored.zip deflated.zip streamed.zip oversized.zip nosoc.zip dofile("drivers/tests/lua_harness/host_mock.lua") -local stored_path, deflated_path = arg[1], arg[2] -assert(stored_path and deflated_path, "usage: test_vag_vehicle.lua stored.zip deflated.zip") +local stored_path, deflated_path, streamed_path, oversized_path, nosoc_path = + arg[1], arg[2], arg[3], arg[4], arg[5] +assert(stored_path and deflated_path and streamed_path and oversized_path and nosoc_path, + "usage: test_vag_vehicle.lua stored.zip deflated.zip streamed.zip oversized.zip nosoc.zip") local function read_bin(path) local f = assert(io.open(path, "rb")) @@ -15,15 +17,22 @@ end local stored_zip = read_bin(stored_path) local deflated_zip = read_bin(deflated_path) -assert(#stored_zip > 0 and #deflated_zip > 0, "empty zip fixture") +local streamed_zip = read_bin(streamed_path) +local oversized_zip = read_bin(oversized_path) +local nosoc_zip = read_bin(nosoc_path) +assert(#stored_zip > 0 and #deflated_zip > 0 and #streamed_zip > 0, "empty zip fixture") local VIN = "WVWZZZTESTVIN0001" local REQ = "req-continuous-1" +local OLD = "2026-09-26T06-45-00_partial.zip" local FILE = "2026-09-26T07-00-00_partial.zip" +local NEXT = "2026-09-26T07-15-00_partial.zip" local META = "/datarequest/vehicles/" .. VIN .. "/metadata/partial" local LIST = "/datadelivery/vehicles/" .. VIN .. "/" .. REQ .. "/list" local DL = "/datadelivery/vehicles/" .. VIN .. "/" .. REQ .. "/download" +local POLL_MS = 300000 +-- Downloads are served by the filename header, as the portal does. local orig_http_get = host.http_get function host.http_get(url, headers) if host._http_errors then @@ -33,11 +42,14 @@ function host.http_get(url, headers) end end end - if host._download_only and string.find(url, DL, 1, true) then + if string.find(url, DL, 1, true) then local fn = headers and headers.filename - if fn ~= host._download_only then - return nil, "HTTP 404: wrong file " .. tostring(fn) + table.insert(host._download_log, fn) + local body = host._downloads[fn] + if body == nil then + return nil, "HTTP 404: no file " .. tostring(fn) end + return body end return orig_http_get(url, headers) end @@ -45,7 +57,8 @@ end local function boot(cfg) host.reset() host._http_errors = {} - host._download_only = nil + host._downloads = {} + host._download_log = {} dofile("drivers/lua/vag_vehicle.lua") driver_init(cfg or { vin = VIN, @@ -54,95 +67,180 @@ local function boot(cfg) }) end -local function prime(zip_body, file_name) +local function listing(files) host._http_responses[META] = host.json_encode({ Identifier = REQ }) - host._http_responses[LIST] = host.json_encode({ - { name = file_name or FILE, createdOn = "2026-09-26T07:00:00Z" }, - }) - host._http_responses[DL] = zip_body + host._http_responses[LIST] = host.json_encode(files) +end + +local function file(name, created) + return { name = name, createdOn = created } +end + +local function rows() + return host._emitted.vehicle or {} +end + +local function last_row() + local r = rows() + return r[#r] +end + +local function last_poll_interval() + local ms = nil + for _, call in ipairs(host._calls) do + if call.func == "set_poll_interval" then ms = call.args[1] end + end + return ms end +local function logged(needle) + for _, line in ipairs(host._logs) do + if string.find(line, needle, 1, true) then return true end + end + return false +end + +-- Porsche is not on this portal. boot({ brand = "porsche", vin = VIN, cookie = "x=1" }) assert(host._make == nil, "Porsche must not bind identity") driver_poll() -assert(not host._emitted.vehicle, "Porsche must not emit") +assert(#rows() == 0, "Porsche must not emit") +-- Identity binds without a cookie; nothing is emitted, and the poll +-- interval is still set (the host keeps the last one set). boot({ brand = "audi", vin = VIN }) assert(host._make == "Audi" and host._sn == VIN, "identity binds before cookie") driver_poll() -assert(not host._emitted.vehicle, "missing cookie must not emit") +assert(#rows() == 0, "missing cookie must not emit") +assert(last_poll_interval() == POLL_MS, "missing cookie left the poll interval unset") +-- The newest file at the first listing may be hours old: it is reported, +-- but stale and not fresh. A file that appears after it is fresh. boot() assert(host._make == "Volkswagen", "set_make from brand") assert(host._sn == VIN, "set_sn from VIN") - -prime(stored_zip) +listing({ file(FILE, "2026-09-26T07:00:00Z") }) +host._downloads[FILE] = stored_zip driver_poll() -local rows = host._emitted.vehicle -assert(rows and #rows == 1, "stored zip should emit once") -local first = rows[1] +assert(#rows() == 1, "first file emits once") +local first = rows()[1] assert(first.soc == 63, "soc from battery_level_HV.value, got " .. tostring(first.soc)) -assert(first.charge_limit_pct == 80, "target soc") -assert(first.charging_state == "Charging", "charging_state mapped") -assert(first.time_to_full_min == 42, "remaining_charging_time") -assert(first.stale == false, "fresh dataset is not stale") -assert(first.soc_fresh == true, "new file is a fresh observation") +assert(first.soc_fresh == false, "first file after start is not a fresh observation") +assert(first.stale == true, "first file after start has unknown age") + +driver_poll() +assert(#host._download_log == 1, "the same file is not downloaded again") +assert(#rows() == 2 and last_row().stale == true, "replay keeps the unknown age") + +listing({ file(FILE, "2026-09-26T07:00:00Z"), file(NEXT, "2026-09-26T07:15:00Z") }) +host._downloads[NEXT] = deflated_zip +driver_poll() +local fresh = last_row() +assert(fresh.soc == 63, "deflate extract soc") +assert(fresh.soc_fresh == true, "a file that appeared after start is fresh") +assert(fresh.stale == false, "fresh file is not stale") +assert(fresh.charge_limit_pct == 80, "target soc") +assert(fresh.charging_state == "Charging", "charging_state mapped") +assert(fresh.time_to_full_min == 42, "remaining_charging_time") -local before = #rows driver_poll() -assert(#host._emitted.vehicle == before + 1, "same file still reports last reading") -local replay = host._emitted.vehicle[#host._emitted.vehicle] -assert(replay.soc == 63, "replay keeps soc") -assert(replay.soc_fresh == false, "same zip is not a new observation") +assert(last_row().soc_fresh == false, "same file again is a replay") +assert(last_row().stale == false, "young replay is not stale") +host._millis_counter = host._millis_counter + 2700001 +host._http_errors[LIST] = "HTTP 404: no files" +local before = #rows() +driver_poll() +assert(#rows() == before, "stale last reading must stop emitting") + +-- A zip written by a streaming writer: sizes in a data descriptor. +boot() +listing({ file(FILE, "2026-09-26T07:00:00Z") }) +host._downloads[FILE] = streamed_zip +driver_poll() +assert(last_row() and last_row().soc == 63, "streamed zip entry decodes") + +-- A dataset over the size cap is refused, once. +boot() +listing({ file(FILE, "2026-09-26T07:00:00Z") }) +host._downloads[FILE] = oversized_zip +driver_poll() +assert(#rows() == 0, "oversized dataset must not emit") +assert(logged("dataset too large"), "oversized dataset is logged") +driver_poll() +assert(#host._download_log == 1, "a refused file is not downloaded again") + +-- A new file without SoC is read once and replays the last reading. boot() -prime(deflated_zip) +listing({ file(OLD, "2026-09-26T06:45:00Z") }) +host._downloads[OLD] = stored_zip +driver_poll() +listing({ file(OLD, "2026-09-26T06:45:00Z"), file(FILE, "2026-09-26T07:00:00Z") }) +host._downloads[FILE] = nosoc_zip driver_poll() -rows = host._emitted.vehicle -assert(rows and #rows == 1, "deflated zip should emit") -assert(rows[1].soc == 63, "deflate extract soc") -assert(rows[1].charging_state == "Charging", "deflate extract state") +assert(last_row().soc_fresh == false, "a file without SoC is not a fresh reading") +driver_poll() +assert(#host._download_log == 2, "a file without SoC is not downloaded again") +-- 401 before the first reading emits nothing. boot() host._http_errors[META] = "HTTP 401: unauthorized" driver_poll() -assert(not host._emitted.vehicle, "401 before first reading emits nothing") +assert(#rows() == 0, "401 before first reading emits nothing") +-- An expired session replays the last reading. boot() -prime(stored_zip) +listing({ file(FILE, "2026-09-26T07:00:00Z") }) +host._downloads[FILE] = stored_zip driver_poll() -assert(#host._emitted.vehicle == 1, "seed reading") +assert(#rows() == 1, "seed reading") host._http_errors[LIST] = "HTTP 403: forbidden" driver_poll() -assert(#host._emitted.vehicle == 2, "expired session may replay last") -assert(host._emitted.vehicle[2].soc_fresh == false, "401/403 replay is cached") +assert(#rows() == 2, "expired session may replay last") +assert(rows()[2].soc_fresh == false, "401/403 replay is cached") +-- Asleep at start: no content file. The first content file after that is +-- a new observation. boot() -prime(stored_zip) +listing({ file("2026-09-26T06-00-00_no_content_found.zip", "2026-09-26T06:00:00Z") }) driver_poll() -host._millis_counter = host._millis_counter + 2700001 -host._http_errors[LIST] = "HTTP 404: no files" +assert(#rows() == 0, "asleep placeholder must not invent SoC") +listing({ + file("2026-09-26T06-00-00_no_content_found.zip", "2026-09-26T06:00:00Z"), + file(FILE, "2026-09-26T07:00:00Z"), +}) +host._downloads[FILE] = stored_zip driver_poll() -assert(#host._emitted.vehicle == 1, "stale last reading must stop emitting") +assert(last_row() and last_row().soc_fresh == true, "content after an asleep start is fresh") +-- Portal order is not a contract: the newest createdOn wins. boot() -host._http_responses[META] = host.json_encode({ Identifier = REQ }) -host._http_responses[LIST] = host.json_encode({ - { name = "2026-09-26T06-00-00_no_content_found.zip", createdOn = "2026-09-26T06:00:00Z" }, +listing({ + file(FILE, "2026-09-26T07:00:00Z"), + file("2026-09-26T06-00-00_partial.zip", "2026-09-26T06:00:00Z"), }) +host._downloads[FILE] = stored_zip driver_poll() -assert(not host._emitted.vehicle, "asleep placeholder must not invent SoC") +assert(host._download_log[1] == FILE, "newest createdOn is downloaded") +assert(last_row() and last_row().soc == 63, "newest createdOn wins even when it is not last") +-- Several candidate fields: the newest point wins, as in evcc. Battery care +-- mode's threshold counts as the limit only while the mode is active. boot() -prime(stored_zip) --- Older file last: a naive "last array element" pick would request it and fail. -host._download_only = FILE -host._http_responses[LIST] = host.json_encode({ - { name = FILE, createdOn = "2026-09-26T07:00:00Z" }, - { name = "2026-09-26T06-00-00_partial.zip", createdOn = "2026-09-26T06:00:00Z" }, -}) +listing({ file(OLD, "2026-09-26T06:45:00Z") }) +host._downloads[OLD] = stored_zip +driver_poll() +listing({ file(OLD, "2026-09-26T06:45:00Z"), file(FILE, "2026-09-26T07:00:00Z") }) +host._downloads[FILE] = host.json_encode({ vin = VIN, Data = { + { key = "162c2a75-edf4-3990-b8ed-7c600b3dbc40", dataFieldName = "battery_level_HV.value", value = "50" }, + { key = "f89ed652-d104-3fa6-b7e2-ab7543309e7b", dataFieldName = "hv_soc", value = "55" }, + { key = "76acaa98-37ef-3466-b013-21c77ed343ae", dataFieldName = "settings.target_soc", value = "90" }, + { dataFieldName = "setting.bcam_activation", value = "BCAM_ACTIVATION_ACTIVATED" }, + { dataFieldName = "battery_care_mode.charge_bcam_threshold", value = "80" }, +} }) driver_poll() -assert(host._emitted.vehicle and host._emitted.vehicle[1].soc == 63, - "newest createdOn wins even when it is not last") +assert(last_row().soc == 55, "newest SoC point wins, got " .. tostring(last_row().soc)) +assert(last_row().charge_limit_pct == 80, "active care mode threshold is the newest limit") +assert(last_row().soc_fresh == true, "new file is fresh") print("vag_vehicle: ok") diff --git a/drivers/tests/test_vag_vehicle.py b/drivers/tests/test_vag_vehicle.py index a97ba90..f6ac5a6 100644 --- a/drivers/tests/test_vag_vehicle.py +++ b/drivers/tests/test_vag_vehicle.py @@ -1,6 +1,8 @@ """VAG EU Data Act driver: identity, zip decode, freshness, 401.""" from pathlib import Path import io +import json +import struct import subprocess import zipfile @@ -15,26 +17,65 @@ {"key":"cf28f7d9-6201-30b8-82e5-a461968d30dc","dataFieldName":"remaining_charging_time","value":"42","timestampUtc":"2026-09-26T07:00:00Z"} ]}""" +NO_SOC = json.dumps({"vin": "WVWZZZTESTVIN0001", "Data": [ + {"key": "9da735bb-c5d5-39f8-bf53-0fa2a367aa8f", "dataFieldName": "charging_state", + "value": "charging", "timestampUtc": "2026-09-26T07:15:00Z"}, +]}) -def _zip(method: int) -> bytes: + +def _zip(method: int, text: str = DATASET) -> bytes: buf = io.BytesIO() with zipfile.ZipFile(buf, "w", compression=method) as zf: - zf.writestr("dataset.json", DATASET) + zf.writestr("dataset.json", text) return buf.getvalue() +class _WriteOnly(io.RawIOBase): + """A stream zipfile cannot seek back into, like a streamed HTTP body.""" + + def __init__(self) -> None: + self.data = bytearray() + + def writable(self) -> bool: + return True + + def write(self, b) -> int: + self.data += b + return len(b) + + +def _zip_streamed() -> bytes: + sink = _WriteOnly() + with zipfile.ZipFile(sink, "w", compression=zipfile.ZIP_DEFLATED) as zf: + zf.writestr("dataset.json", DATASET) + blob = bytes(sink.data) + flags, = struct.unpack_from(" bytes: + # About 5 MB of JSON that deflates to a few kB: over the driver's 4 MiB cap. + text = json.dumps({"vin": "WVWZZZTESTVIN0001", "Data": [], "pad": " " * 5_000_000}) + return _zip(zipfile.ZIP_DEFLATED, text) + + def test_vag_vehicle_dataset_and_freshness(tmp_path: Path) -> None: - stored = tmp_path / "stored.zip" - deflated = tmp_path / "deflated.zip" - stored.write_bytes(_zip(zipfile.ZIP_STORED)) - deflated.write_bytes(_zip(zipfile.ZIP_DEFLATED)) + fixtures = { + "stored": _zip(zipfile.ZIP_STORED), + "deflated": _zip(zipfile.ZIP_DEFLATED), + "streamed": _zip_streamed(), + "oversized": _zip_oversized(), + "nosoc": _zip(zipfile.ZIP_DEFLATED, NO_SOC), + } + paths = [] + for name, blob in fixtures.items(): + path = tmp_path / f"{name}.zip" + path.write_bytes(blob) + paths.append(str(path)) result = subprocess.run( - [ - str(ROOT / "lua55"), - "drivers/tests/lua_harness/test_vag_vehicle.lua", - str(stored), - str(deflated), - ], + [str(ROOT / "lua55"), "drivers/tests/lua_harness/test_vag_vehicle.lua", *paths], cwd=ROOT, text=True, capture_output=True, diff --git a/index.yaml b/index.yaml index 471972c..5d12cf1 100644 --- a/index.yaml +++ b/index.yaml @@ -735,8 +735,8 @@ drivers: setup: [vendor_portal] ders: [vehicle] control: false - size_bytes: 24508 - sha256: "2f01b85b115acf754b90553ec580b7a8c543c01a15feb65c464e4e41b5a79bf5" + size_bytes: 27869 + sha256: "eaa1b985db5deb897a1aa95596eeb8bedc6c0ac50cabcf14e273a9070edfe227" - name: "varta" version: "1.1.1" tier: community diff --git a/manifests/vag_vehicle.yaml b/manifests/vag_vehicle.yaml index 52a60af..ab5d14d 100644 --- a/manifests/vag_vehicle.yaml +++ b/manifests/vag_vehicle.yaml @@ -13,17 +13,17 @@ tested_devices: variants: [ID.3, ID.4, Enyaq, Q4 e-tron] regions: [EU] firmware_versions: "" - notes: "Read-only SoC and charge state from the VW Group EU Data Act portal. We Connect third-party APIs are blocked. Owner must enable a continuous 15-minute All Data request and paste a portal session cookie. Not live BMS; charging does not need this cloud. Porsche is not on this portal. Not yet exercised on a live FTW site." + notes: "Read-only SoC and charge state from the VW Group EU Data Act portal. We Connect third-party APIs are blocked. Owner must enable a continuous 15-minute All Data request and paste a portal session cookie. Not live BMS; charging does not need this cloud. Porsche is not on this portal. Not yet run against the portal or a car." min_driver_version: "0.1.0" upstream_docs: - - url: "https://eu-data-act.drivesomethinggreater.com/" - title: "VW Group EU Data Act portal" - kind: api_docs + - url: "https://raw.githubusercontent.com/evcc-io/evcc/master/vehicle/vw/eudataact/datadictionary.json" + title: "VW Group EU Data Act data dictionary V5.0, as parsed by evcc" + kind: other url_stability: stable min_host_version: "2.0.0" -size_bytes: 24508 +size_bytes: 27869 dkb_id: "vag_vehicle" -sha256: "2f01b85b115acf754b90553ec580b7a8c543c01a15feb65c464e4e41b5a79bf5" +sha256: "eaa1b985db5deb897a1aa95596eeb8bedc6c0ac50cabcf14e273a9070edfe227" signature: "" bytecode_sha256: ""