From 2a69b5ee169db9380639d5a5cc9a8975c2d698a8 Mon Sep 17 00:00:00 2001 From: David Mozart Date: Wed, 4 Nov 2026 11:43:19 +0100 Subject: [PATCH] fix(zap): read PV and battery under data model v2 (3.1.3) Zap firmware 7.x calls the PV DER solar, sends power and energy on one side, _ac or _dc, and moves the ratings to /api/devices. read_pv and read_battery therefore read nothing on 7.x. The driver now reads the 1.x and the v2 names, and a value v2 sends as null stays unknown. An AC-side voltage or current is not reported as the battery's own. The inverter DER is not counted as another resource. Also removes draft comments and a pinned version from the meter schema test. Co-Authored-By: Claude Opus 5.5 Signed-off-by: David Mozart --- CHANGELOG.md | 19 ++ SUPPORT_STATUS.md | 4 +- devices.yaml | 2 +- drivers/lua/zap.lua | 102 +++++++--- .../lua_harness/test_zap_meter_schema.lua | 11 +- .../test_zap_pv_battery_schema.lua | 188 ++++++++++++++++++ drivers/tests/test_zap_meter_schema.py | 8 +- drivers/tests/test_zap_pv_battery_schema.py | 36 ++++ index.yaml | 6 +- manifests/zap.yaml | 6 +- support-status.json | 2 +- 11 files changed, 325 insertions(+), 59 deletions(-) create mode 100644 drivers/tests/lua_harness/test_zap_pv_battery_schema.lua create mode 100644 drivers/tests/test_zap_pv_battery_schema.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b8194d3..b1affa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ ## [Unreleased] +### zap 3.1.3 + +`read_pv` and `read_battery` read again on Zap firmware 7.x. Data model v2 calls +the PV DER `solar`, puts power and energy on one side (`_ac` or `_dc`), and moves +the ratings to `/api/devices`, so 3.1.2 found no PV and no battery power there. + +- PV and battery read on both the 1.x and the v2 names. A value v2 sends as + `null` stays unknown. +- An AC-side voltage or current is not reported as the battery's own. +- The `inverter` DER is not counted as another resource, and `ev_charger_port` + counts as a charger. + +Checked through FTW's Lua host on firmware 7.0.3, against a real Zap-P1 with a +simulated Solis. Not checked against a real inverter. Without `read_pv` and +`read_battery` the driver still reads the meter and nothing else. + +FTW's bundled copy does not move with this commit. Bump the pin and run +`scripts/sync-bundled-drivers.sh`. + ### zap 3.1.2 The P1/HAN site meter reads again on Zap firmware serving the data model v2 diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index ec9152b..72c770c 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -180,8 +180,8 @@ Catalog source is not proof that a target can install or run a driver. | victron_mqtt | 1.0.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | wallbox | 1.0.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | | wallbox | 1.0.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | -| zap | 3.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| zap | 3.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| zap | 3.1.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| zap | 3.1.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | zaptec | 1.0.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | | zaptec | 1.0.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | zaptec_cloud | 0.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | diff --git a/devices.yaml b/devices.yaml index f1df6c2..7936061 100644 --- a/devices.yaml +++ b/devices.yaml @@ -1749,7 +1749,7 @@ manufacturers: protocols: - protocol: http driver: "zap" - version: "3.1.2" + version: "3.1.3" ders: [meter, pv, battery] control: false firmware_versions: "" diff --git a/drivers/lua/zap.lua b/drivers/lua/zap.lua index 06dec0b..7b7922f 100644 --- a/drivers/lua/zap.lua +++ b/drivers/lua/zap.lua @@ -32,13 +32,17 @@ -- pv: -W generation -- battery:+W charge, -W discharge -- --- Meter schema compatibility: some Zap firmware serves the local data model --- v2 (srcful-data-models 2.3.0, "version":"v2" in /data/json), which renames --- every meter field with an "_ac" suffix and lowercases the L1/L2/L3 phase --- prefix (e.g. "L1_V" -> "l1_V_ac", "W" -> "W_ac"; "Hz" is unchanged either --- way). This driver reads both v1 and v2 field names for the meter DER; see --- https://github.com/srcfl/device-drivers/issues/170 for the full v2 scope --- (pv -> solar, battery, DC/AC split), which is not yet covered here. +-- Data model v2: Zap firmware 7.x serves the local API in data model v2 +-- ("version":"v2" in /data/json). Older firmware uses the 1.x names. This +-- driver reads both, and a value v2 sends as null stays unknown. v2 moved: +-- meter: W -> W_ac, L1_V -> l1_V_ac, total_import_Wh -> total_import_Wh_ac +-- pv: the DER is "solar"; power and energy are on one side, _ac or _dc; +-- mppt1_V -> mppt[1].V_dc; heatsink_C -> inverter.temperature_C +-- battery: power and energy likewise; V -> V_dc, A -> A_dc; +-- SoC_nom_fract -> soc_nom_fract; heatsink_C -> temperature_C +-- ratings: from /api/devices: rated_power_W_ac on the "inverter" DER, +-- installed_power_W_dc on "solar", rated_power_W_dc and +-- capacity_Wh_dc on "battery" DRIVER = { host_api_min = 1, @@ -46,7 +50,7 @@ DRIVER = { id = "zap", name = "Sourceful Zap (P1/HAN meter)", manufacturer = "Sourceful", - version = "3.1.2", + version = "3.1.3", protocols = { "http" }, capabilities = { "meter", "pv", "battery" }, read_only = true, @@ -57,7 +61,7 @@ DRIVER = { verification_status = "production", verified_by = { "erikarenhill@fortytwo:3d" }, verified_at = "2026-07-17", - verification_notes = "P1/HAN live-hardware verified against the official Zap Local API (srcful-zap-x-firmware 96e0258). PV aggregation was live-hardware verified on the same API before the 3.0 meter-only cut; it returns as an operator opt-in. Battery payloads are contract-tested against the firmware serializers. Inverters, batteries and chargers found on Zap are not ingested unless read_pv / read_battery is set.", + verification_notes = "P1/HAN live-hardware verified against the official Zap Local API (srcful-zap-x-firmware 96e0258). PV aggregation was live-hardware verified on the same API before the 3.0 meter-only cut; it returns as an operator opt-in. Battery payloads are contract-tested against the firmware serializers. Inverters, batteries and chargers found on Zap are not ingested unless read_pv / read_battery is set. Data model v2 checked 2026-11-04 on firmware 7.0.3: the meter on a Zap-HAN with a real meter; PV and battery on a Zap-P1 with a simulated Solis, not on a real inverter.", tested_models = { "Sourceful Zap (ESP32-C3 controller firmware)" }, connection_defaults = { host = "zap.local", @@ -98,6 +102,24 @@ local function number(v) return n end +local function positive(v) + local n = number(v) + if n ~= nil and n > 0 then return n end + return nil +end + +-- Data model v2 sends a power or energy value on one side, _ac or _dc, and the +-- other side null. A 1.x payload has the plain name. +local function sided(raw, name) + return number(raw[name]) or number(raw[name .. "_ac"]) or number(raw[name .. "_dc"]) +end + +-- A battery's or a string's own voltage and current. An AC-side value is the +-- grid's, not theirs. +local function dc_side(raw, name) + return number(raw[name]) or number(raw[name .. "_dc"]) +end + local function cfg_true(v) return v == true or v == "true" or v == 1 or v == "1" end @@ -252,22 +274,29 @@ local function discover_devices() if der.type == "meter" then rec.meter = true if not first_meter then first_meter = sn end - elseif der.type == "pv" then + elseif der.type == "pv" or der.type == "solar" then if read_pv then rec.pv = true - rec.pv_rated_w = number(der.rated_power) or number(der.installed_power) or rec.pv_rated_w + rec.pv_rated_w = number(der.rated_power) or number(der.installed_power) + or number(der.installed_power_W_dc) or rec.pv_rated_w else add_other(extras, seen, "PV inverter") end elseif der.type == "battery" then if read_battery then rec.battery = true - rec.battery_rated_w = number(der.rated_power) or rec.battery_rated_w - rec.battery_capacity_wh = number(der.capacity) or rec.battery_capacity_wh + rec.battery_rated_w = number(der.rated_power) or number(der.rated_power_W_dc) + or rec.battery_rated_w + rec.battery_capacity_wh = number(der.capacity) or number(der.capacity_Wh_dc) + or rec.battery_capacity_wh else add_other(extras, seen, "battery") end - elseif der.type == "v2x_charger" or der.type == "ev" then + elseif der.type == "inverter" then + -- The AC stage and rating of a device already + -- listed, not a resource of its own. + rec.inverter_rated_w = positive(der.rated_power_W_ac) or rec.inverter_rated_w + elseif der.type == "v2x_charger" or der.type == "ev" or der.type == "ev_charger_port" then add_other(extras, seen, "charger") elseif der.type ~= nil and der.type ~= "" and der.type ~= "meter" then add_other(extras, seen, tostring(der.type)) @@ -454,6 +483,11 @@ local function snapshot_map() return out end +local function mppt_input(pv, index) + local inputs = type(pv.mppt) == "table" and pv.mppt or {} + return type(inputs[index]) == "table" and inputs[index] or {} +end + local function emit_pv(snapshots) local source_count = count_kind("pv") if source_count == 0 then return end @@ -467,14 +501,14 @@ local function emit_pv(snapshots) for _, rec in ipairs(tracked) do local data = snapshots[rec.sn] - local pv = data and data.pv + local pv = data and (data.pv or data.solar) if rec.pv and type(pv) == "table" then - local rated = number(pv.rated_power_W) or rec.pv_rated_w + local rated = number(pv.rated_power_W) or rec.inverter_rated_w or rec.pv_rated_w if rated and rated > 0 then total_rated_w = total_rated_w + rated any_rating = true end - local w = sane_power(pv.W, rated) + local w = sane_power(sided(pv, "W"), rated) if w ~= nil and w <= 0 then total_w = total_w + w any_power = true @@ -485,18 +519,20 @@ local function emit_pv(snapshots) host.log("warn", "Zap: rejected positive PV power for " .. rec.sn .. ": " .. w .. "W") end - local generation = bounded(pv.total_generation_Wh, 0, nil) + local generation = bounded(sided(pv, "total_generation_Wh"), 0, nil) if generation ~= nil then total_generation_wh = total_generation_wh + generation any_generation = true end local tag = source_count > 1 and ("_" .. metric_tag(rec.sn)) or "" - emit_optional_metric("pv_heatsink_c" .. tag, number(pv.heatsink_C), "°C") - emit_optional_metric("pv_mppt1_v" .. tag, number(pv.mppt1_V), "V") - emit_optional_metric("pv_mppt1_a" .. tag, number(pv.mppt1_A), "A") - emit_optional_metric("pv_mppt2_v" .. tag, number(pv.mppt2_V), "V") - emit_optional_metric("pv_mppt2_a" .. tag, number(pv.mppt2_A), "A") + local inverter = type(data.inverter) == "table" and data.inverter or {} + local mppt1, mppt2 = mppt_input(pv, 1), mppt_input(pv, 2) + emit_optional_metric("pv_heatsink_c" .. tag, number(pv.heatsink_C) or number(inverter.temperature_C), "°C") + emit_optional_metric("pv_mppt1_v" .. tag, number(pv.mppt1_V) or number(mppt1.V_dc), "V") + emit_optional_metric("pv_mppt1_a" .. tag, number(pv.mppt1_A) or number(mppt1.A_dc), "A") + emit_optional_metric("pv_mppt2_v" .. tag, number(pv.mppt2_V) or number(mppt2.V_dc), "V") + emit_optional_metric("pv_mppt2_a" .. tag, number(pv.mppt2_A) or number(mppt2.A_dc), "A") end end @@ -544,7 +580,7 @@ local function emit_battery(snapshots) total_rated_w = total_rated_w + rated any_rating = true end - local w = sane_power(battery.W, rated) + local w = sane_power(sided(battery, "W"), rated) if w ~= nil then total_w = total_w + w any_power = true @@ -552,7 +588,7 @@ local function emit_battery(snapshots) -- Firmware SoC_nom_fract is 0-1. Identifier keeps the -- catalog check that soc is already a fraction, not percent. - local soc_already_fract = bounded(battery.SoC_nom_fract, 0, 1) + local soc_already_fract = bounded(number(battery.SoC_nom_fract) or number(battery.soc_nom_fract), 0, 1) local soc = soc_already_fract if soc ~= nil then soc_sum = soc_sum + soc @@ -565,17 +601,17 @@ local function emit_battery(snapshots) end end - local charge = bounded(battery.total_charge_Wh, 0, nil) + local charge = bounded(sided(battery, "total_charge_Wh"), 0, nil) if charge ~= nil then total_charge_wh = total_charge_wh + charge; any_charge_energy = true end - local discharge = bounded(battery.total_discharge_Wh, 0, nil) + local discharge = bounded(sided(battery, "total_discharge_Wh"), 0, nil) if discharge ~= nil then total_discharge_wh = total_discharge_wh + discharge; any_discharge_energy = true end local tag = source_count > 1 and ("_" .. metric_tag(rec.sn)) or "" if source_count > 1 and w ~= nil then host.emit_metric("battery_w" .. tag, w, "W") end if source_count > 1 and soc ~= nil then host.emit_metric("battery_soc" .. tag, soc, "fraction") end - emit_optional_metric("battery_dc_v" .. tag, number(battery.V), "V") - emit_optional_metric("battery_dc_a" .. tag, number(battery.A), "A") - emit_optional_metric("battery_temp_c" .. tag, number(battery.heatsink_C), "°C") + emit_optional_metric("battery_dc_v" .. tag, dc_side(battery, "V"), "V") + emit_optional_metric("battery_dc_a" .. tag, dc_side(battery, "A"), "A") + emit_optional_metric("battery_temp_c" .. tag, number(battery.heatsink_C) or number(battery.temperature_C), "°C") end end @@ -600,9 +636,9 @@ local function emit_battery(snapshots) if any_rating then battery_reading.rated_w = total_rated_w end if source_count == 1 and single then - battery_reading.v = number(single.V) - battery_reading.a = number(single.A) - battery_reading.temp_c = number(single.heatsink_C) + battery_reading.v = dc_side(single, "V") + battery_reading.a = dc_side(single, "A") + battery_reading.temp_c = number(single.heatsink_C) or number(single.temperature_C) end host.emit("battery", battery_reading) diff --git a/drivers/tests/lua_harness/test_zap_meter_schema.lua b/drivers/tests/lua_harness/test_zap_meter_schema.lua index 4dc7e87..97cb564 100644 --- a/drivers/tests/lua_harness/test_zap_meter_schema.lua +++ b/drivers/tests/lua_harness/test_zap_meter_schema.lua @@ -1,12 +1,5 @@ -- Exercise zap.lua's meter field reading against both the v1 and v2 Zap -- local API field names. Run with the scenario as arg[1]: "v1" or "v2". --- --- This file is a draft for drivers/tests/lua_harness/test_zap_meter_schema.lua --- in srcfl/device-drivers — see pr-drafts/README.md for how this fits that --- repo's `make test-driver ID=zap` harness. Not run against the real harness --- yet (no local checkout with lua55/pytest); matches the structure of the --- existing drivers/tests/lua_harness/test_esphome_dsmr_aliases.lua, which --- solves the same kind of field-name-aliasing problem. local scenario = arg[1] if scenario ~= "v1" and scenario ~= "v2" then @@ -50,8 +43,8 @@ end dofile("drivers/lua/zap.lua") -if DRIVER.id ~= "zap" or DRIVER.version ~= "3.1.2" then - error("Zap identity metadata is wrong or version was not bumped") +if DRIVER.id ~= "zap" then + error("Zap identity metadata is wrong") end if DRIVER.host_api_min ~= 1 or DRIVER.host_api_max ~= 1 or DRIVER.read_only ~= true then error("Zap host API or read-only metadata is wrong") diff --git a/drivers/tests/lua_harness/test_zap_pv_battery_schema.lua b/drivers/tests/lua_harness/test_zap_pv_battery_schema.lua new file mode 100644 index 0000000..b165fbd --- /dev/null +++ b/drivers/tests/lua_harness/test_zap_pv_battery_schema.lua @@ -0,0 +1,188 @@ +-- Exercise zap.lua's read_pv and read_battery on the 1.x and the data model v2 +-- local API. Run with the scenario as arg[1]. The v2 payload is what a Zap on +-- firmware 7.0.3 sent for a simulated Solis, with the serial replaced. + +local scenario = arg[1] + +dofile("drivers/tests/lua_harness/host_mock.lua") +host.reset() + +local function respond(path, body) + host._http_responses["http://127.0.0.1" .. path] = body +end + +local V1_DEVICES = [[ +{"devices":[{"sn":"INV-1","type":"modbus_tcp","device_type":"inverter","ders":[ + {"type":"pv","enabled":false,"rated_power":20000}, + {"type":"battery","enabled":false,"rated_power":5000,"capacity":15000}]}]} +]] + +local V1_DATA = [[ +{"pv":{"type":"pv","W":-3000,"rated_power_W":20000,"total_generation_Wh":150000, + "mppt1_V":300,"mppt1_A":-5,"mppt2_V":290,"mppt2_A":-4.8,"heatsink_C":45}, + "battery":{"type":"battery","W":5000,"V":52,"A":10,"SoC_nom_fract":0.75, + "rated_power_W":5000,"capacity_Wh":15000, + "total_charge_Wh":4200000,"total_discharge_Wh":1800000,"heatsink_C":24}, + "version":"v1","format":"json"} +]] + +local V2_DEVICES = [[ +{"count":1,"devices":[{"type":"modbus_tcp","profile":"solis","device_type":"inverter","sn":"INV-1","ders":[ + {"type":"solar","enabled":true,"installed_power_W_dc":8000}, + {"type":"battery","enabled":true,"rated_power_W_dc":5000,"capacity_Wh_dc":15000}, + {"type":"inverter","enabled":true,"rated_power_W_ac":20000}]}]} +]] + +local V2_DATA = [[ +{"solar":{"type":"solar","make":"solis","W_ac":null,"W_dc":-3000, + "mppt":[{"V_dc":300,"A_dc":-5,"W_dc":-1500},{"V_dc":290,"A_dc":-4.8,"W_dc":-1392}], + "total_generation_Wh_ac":null,"total_generation_Wh_dc":150000, + "lower_limit_W_ac":null,"lower_limit_W_dc":null}, + "battery":{"type":"battery","make":"solis","W_ac":null,"W_dc":5000,"V_ac":null,"V_dc":52, + "A_ac":null,"A_dc":10,"soc_nom_fract":0.75,"soh_fract":null,"temperature_C":24, + "total_charge_Wh_ac":null,"total_charge_Wh_dc":4200000, + "total_discharge_Wh_ac":null,"total_discharge_Wh_dc":1800000, + "upper_limit_W_ac":null,"upper_limit_W_dc":5000,"setpoint_W_ac":null,"setpoint_W_dc":5000}, + "inverter":{"type":"inverter","make":"solis","W_ac":-2400,"temperature_C":45,"rated_power_W_ac":20000}, + "version":"v2","format":"json"} +]] + +local function near(actual, expected) + return type(actual) == "number" and math.abs(actual - expected) < 0.001 +end + +local function want(name, actual, expected) + if not near(actual, expected) then + error(scenario .. ": " .. name .. " = " .. tostring(actual) .. ", want " .. tostring(expected)) + end +end + +local function metric(name) + local entry = host._metrics[name] + return entry and entry.value +end + +local function poll(config) + dofile("drivers/lua/zap.lua") + config.host = "127.0.0.1" + driver_init(config) + local ok, err = pcall(driver_poll) + if not ok then error("driver_poll failed: " .. tostring(err)) end +end + +local function assert_full_reading() + local pv = host._emitted.pv and host._emitted.pv[1] + if not pv then error(scenario .. ": no pv reading") end + want("pv.w", pv.w, -3000) + want("pv.total_generation_wh", pv.total_generation_wh, 150000) + want("pv.rated_w", pv.rated_w or pv.rated_power_w, 20000) + + local battery = host._emitted.battery and host._emitted.battery[1] + if not battery then error(scenario .. ": no battery reading") end + want("battery.w", battery.w, 5000) + want("battery.soc", battery.soc, 0.75) + want("battery.v", battery.v, 52) + want("battery.a", battery.a, 10) + want("battery.temp_c", battery.temp_c, 24) + want("battery.total_charge_wh", battery.total_charge_wh, 4200000) + want("battery.total_discharge_wh", battery.total_discharge_wh, 1800000) + want("battery.rated_w", battery.rated_w or battery.rated_power_w, 5000) + + want("pv_heatsink_c", metric("pv_heatsink_c"), 45) + want("pv_mppt1_v", metric("pv_mppt1_v"), 300) + want("pv_mppt1_a", metric("pv_mppt1_a"), -5) + want("pv_mppt2_v", metric("pv_mppt2_v"), 290) + want("pv_mppt2_a", metric("pv_mppt2_a"), -4.8) + want("battery_dc_v", metric("battery_dc_v"), 52) + want("battery_dc_a", metric("battery_dc_a"), 10) +end + +if scenario == "v1" then + respond("/api/devices", V1_DEVICES) + respond("/api/devices/INV-1/data/json", V1_DATA) + poll({ read_pv = true, read_battery = true }) + assert_full_reading() + +elseif scenario == "v2" then + respond("/api/devices", V2_DEVICES) + respond("/api/devices/INV-1/data/json", V2_DATA) + poll({ read_pv = true, read_battery = true }) + assert_full_reading() + +elseif scenario == "v2_panels_rating" then + -- No inverter rating set: the rating falls back on the installed panels. + respond("/api/devices", [[ +{"devices":[{"type":"modbus_tcp","device_type":"inverter","sn":"INV-1","ders":[ + {"type":"solar","installed_power_W_dc":8000}, + {"type":"inverter","rated_power_W_ac":0}]}]} +]]) + respond("/api/devices/INV-1/data/json", [[{"solar":{"W_ac":null,"W_dc":-3000},"version":"v2"}]]) + poll({ read_pv = true }) + want("pv.rated_w", host._emitted.pv[1].rated_w or host._emitted.pv[1].rated_power_w, 8000) + +elseif scenario == "v2_ac_side" then + -- An AC-side power and energy count. An AC-side voltage and current are the + -- grid's, so they are not the battery's. + respond("/api/devices", V2_DEVICES) + respond("/api/devices/INV-1/data/json", [[ +{"solar":{"W_ac":-1000,"W_dc":null,"total_generation_Wh_ac":700,"total_generation_Wh_dc":null}, + "battery":{"W_ac":-400,"W_dc":null,"V_ac":230,"V_dc":null,"A_ac":-1.7,"A_dc":null, + "soc_nom_fract":0.5,"total_charge_Wh_ac":90,"total_charge_Wh_dc":null}, + "version":"v2"} +]]) + poll({ read_pv = true, read_battery = true }) + want("pv.w", host._emitted.pv[1].w, -1000) + want("pv.total_generation_wh", host._emitted.pv[1].total_generation_wh, 700) + local battery = host._emitted.battery[1] + want("battery.w", battery.w, -400) + want("battery.total_charge_wh", battery.total_charge_wh, 90) + if battery.v ~= nil or battery.a ~= nil then + error("v2_ac_side: an AC voltage or current became the battery's") + end + if metric("battery_dc_v") ~= nil or metric("battery_dc_a") ~= nil then + error("v2_ac_side: an AC voltage or current became a DC metric") + end + +elseif scenario == "v2_null" then + -- What was not read stays unknown, never 0. + respond("/api/devices", V2_DEVICES) + respond("/api/devices/INV-1/data/json", [[ +{"solar":{"W_ac":null,"W_dc":null,"mppt":null,"total_generation_Wh_ac":null,"total_generation_Wh_dc":null}, + "battery":{"W_ac":null,"W_dc":null,"V_dc":null,"soc_nom_fract":null}, + "version":"v2"} +]]) + poll({ read_pv = true, read_battery = true }) + if host._emitted.pv then error("v2_null: null power became a pv reading") end + if host._emitted.battery then error("v2_null: null power became a battery reading") end + +elseif scenario == "v2_null_soc" then + respond("/api/devices", V2_DEVICES) + respond("/api/devices/INV-1/data/json", [[ +{"battery":{"W_dc":100,"soc_nom_fract":null,"total_charge_Wh_dc":null},"version":"v2"} +]]) + poll({ read_battery = true }) + local battery = host._emitted.battery[1] + if battery.soc ~= nil or battery.total_charge_wh ~= nil then + error("v2_null_soc: an unread value was invented") + end + +elseif scenario == "v2_not_opted_in" then + -- The defaults are unchanged: nothing but the meter is read, and the v2 + -- inverter DER is not a resource of its own. + respond("/api/devices", [[ +{"devices":[{"type":"modbus_tcp","device_type":"inverter","sn":"INV-1","ders":[ + {"type":"solar"},{"type":"battery"},{"type":"inverter","rated_power_W_ac":20000}]}, + {"type":"mqtt","device_type":"v2x_charger","sn":"EV-1","ders":[{"type":"ev_charger_port"}]}]} +]]) + respond("/api/devices/INV-1/data/json", V2_DATA) + poll({}) + if host._emitted.pv or host._emitted.battery then + error("v2_not_opted_in: read a resource that was not opted in") + end + want("other_resources", metric("other_resources"), 3) + +else + error("unknown scenario: " .. tostring(scenario)) +end + +driver_cleanup() diff --git a/drivers/tests/test_zap_meter_schema.py b/drivers/tests/test_zap_meter_schema.py index 994af2e..ecb7401 100644 --- a/drivers/tests/test_zap_meter_schema.py +++ b/drivers/tests/test_zap_meter_schema.py @@ -1,10 +1,4 @@ -"""Exercise zap.lua's meter reading against the v1 and v2 Zap local API schemas. - -Draft for drivers/tests/test_zap_meter_schema.py in srcfl/device-drivers, -paired with drivers/tests/lua_harness/test_zap_meter_schema.lua. Modeled on -the existing test_esphome_dsmr_aliases.py, which runs the equivalent -field-aliasing check for a different driver through the same lua55 harness. -""" +"""Exercise zap.lua's meter reading against the v1 and v2 Zap local API schemas.""" from pathlib import Path import re diff --git a/drivers/tests/test_zap_pv_battery_schema.py b/drivers/tests/test_zap_pv_battery_schema.py new file mode 100644 index 0000000..7bfc636 --- /dev/null +++ b/drivers/tests/test_zap_pv_battery_schema.py @@ -0,0 +1,36 @@ +"""Exercise zap.lua's read_pv and read_battery on the 1.x and data model v2 APIs.""" + +from pathlib import Path +import subprocess + +import pytest + + +ROOT = Path(__file__).resolve().parents[2] + + +@pytest.mark.parametrize( + "scenario", + [ + "v1", + "v2", + "v2_panels_rating", + "v2_ac_side", + "v2_null", + "v2_null_soc", + "v2_not_opted_in", + ], +) +def test_zap_pv_battery_schema(scenario): + result = subprocess.run( + [ + str(ROOT / "lua55"), + "drivers/tests/lua_harness/test_zap_pv_battery_schema.lua", + scenario, + ], + 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 602079c..a0bdde4 100644 --- a/index.yaml +++ b/index.yaml @@ -814,14 +814,14 @@ drivers: size_bytes: 2842 sha256: "1c0362e0a25f1fe297de838a85089eb7676b25e78a7d6d1d03ccea1258b94403" - name: "zap" - version: "3.1.2" + version: "3.1.3" tier: core protocol: http connectivity: local ders: [meter, pv, battery] control: false - size_bytes: 28162 - sha256: "5be6d7793a9daa074557182e4333443b382a7a34427dd07143d90f27a8cf01b6" + size_bytes: 30379 + sha256: "b11198e45d761599939b8201c0969514bbbbc6c2571dcf56d12959cf9ade2b5d" - name: "zaptec" version: "1.0.1" tier: community diff --git a/manifests/zap.yaml b/manifests/zap.yaml index 4a6e823..91c3258 100644 --- a/manifests/zap.yaml +++ b/manifests/zap.yaml @@ -1,5 +1,5 @@ name: "zap" -version: "3.1.2" +version: "3.1.3" tier: core author: "Sourceful Labs AB" protocol: http @@ -15,9 +15,9 @@ tested_devices: notes: "P1/HAN site meter from a Sourceful Zap. PV and battery telemetry from devices on Zap are opt-in (read_pv / read_battery) and read-only. Prefer a native FTW driver; use Zap as the reader only when it already owns the bus." min_driver_version: "3.1.0" min_host_version: "2.0.0" -size_bytes: 28162 +size_bytes: 30379 dkb_id: "zap" -sha256: "5be6d7793a9daa074557182e4333443b382a7a34427dd07143d90f27a8cf01b6" +sha256: "b11198e45d761599939b8201c0969514bbbbc6c2571dcf56d12959cf9ade2b5d" signature: "" bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index 24ec8f7..403c1de 100644 --- a/support-status.json +++ b/support-status.json @@ -1850,7 +1850,7 @@ }, { "catalog_source": true, - "catalog_version": "3.1.2", + "catalog_version": "3.1.3", "driver_id": "zap", "targets": { "blixt-l1": {