From 3b3d5ab12c263298a7c5037af019f42cb00c5fe7 Mon Sep 17 00:00:00 2001 From: Marcus Murwall Date: Tue, 3 Nov 2026 19:21:26 +0100 Subject: [PATCH] fix(zap): read the meter DER under data model v2 Some Zap gateway firmware serves the local API in data model v2 (srcful-data-models 2.3.0): every meter field gets an "_ac" suffix and the L1/L2/L3 phase prefix is lowercased (W -> W_ac, L1_V -> l1_V_ac; Hz is unchanged). emit_meter read the v1 names unconditionally, so a Zap on v2 firmware gave FTW no meter reading at all. Add raw_field(), which tries the v1 name, then the _ac suffix, then the lowercased-prefix _ac variant -- no explicit version branch needed. Scoped to the meter DER only; pv/battery have the identical assumption against v2's _ac/_dc split and are left to #170, since this site runs read_pv/read_battery both false and I have no v2 hardware with those enabled to verify against. Fixes (partially) #170. Signed-off-by: Marcus Murwall --- CHANGELOG.md | 21 +++++ SUPPORT_STATUS.md | 4 +- devices.yaml | 2 +- drivers/lua/zap.lua | 29 +++++- .../lua_harness/test_zap_meter_schema.lua | 93 +++++++++++++++++++ drivers/tests/test_zap_meter_schema.py | 46 +++++++++ index.yaml | 6 +- manifests/zap.yaml | 6 +- support-status.json | 2 +- 9 files changed, 196 insertions(+), 13 deletions(-) create mode 100644 drivers/tests/lua_harness/test_zap_meter_schema.lua create mode 100644 drivers/tests/test_zap_meter_schema.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f50a0d9..b8194d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ ## [Unreleased] +### zap 3.1.2 + +The P1/HAN site meter reads again on Zap firmware serving the data model v2 +local API. That firmware renames every meter field with an `_ac` suffix and +lowercases the `L1`/`L2`/`L3` phase prefix (`W` → `W_ac`, `L1_V` → `l1_V_ac`; +`Hz` is unchanged). `emit_meter` read the v1 names unconditionally, so a Zap +on v2 firmware gave FTW no meter reading at all — `driver_poll` logged +`Zap: site-meter payload unavailable` every cycle and FTW lost its site +meter, with no site meter data for the home's grid import/export. + +- `emit_meter` now tries the v1 field name first, then the `_ac` suffix, + then the lowercased-phase-prefix `_ac` variant, so it reads either + firmware generation without checking `version` explicitly. +- Scoped to the meter DER only. `pv`/`battery` (`emit_pv`/`emit_battery`) + have the identical `.W`-style field assumption against v2 and are not + covered here — see #170 for that broader scope (`pv` → `solar` rename, + `_dc`/`_ac` split, `null`-vs-`0` handling). + +FTW's bundled copy does not move with this commit. Bump the pin and run +`scripts/sync-bundled-drivers.sh`. + ### tesla_wall_connector 0.1.2 An idle Wall Connector no longer shows charge power. With no car on the diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 21ef6ad..ec9152b 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.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| zap | 3.1.1 | 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 | | 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 cb0dd99..f1df6c2 100644 --- a/devices.yaml +++ b/devices.yaml @@ -1749,7 +1749,7 @@ manufacturers: protocols: - protocol: http driver: "zap" - version: "3.1.1" + version: "3.1.2" ders: [meter, pv, battery] control: false firmware_versions: "" diff --git a/drivers/lua/zap.lua b/drivers/lua/zap.lua index 721c010..06dec0b 100644 --- a/drivers/lua/zap.lua +++ b/drivers/lua/zap.lua @@ -31,6 +31,14 @@ -- meter: +W import, -W export -- 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. DRIVER = { host_api_min = 1, @@ -38,7 +46,7 @@ DRIVER = { id = "zap", name = "Sourceful Zap (P1/HAN meter)", manufacturer = "Sourceful", - version = "3.1.1", + version = "3.1.2", protocols = { "http" }, capabilities = { "meter", "pv", "battery" }, read_only = true, @@ -377,15 +385,30 @@ local meter_fields = { { "total_export_wh", "total_export_Wh", "Wh" }, } +-- Data model v2 renames every meter field with an "_ac" suffix and +-- lowercases the L1/L2/L3 phase prefix (e.g. "L1_V" -> "l1_V_ac"); "Hz" is +-- unchanged. Try the v1 name first, then the v2 variants, so this driver +-- reads either firmware generation without needing to branch on "version". +local function raw_field(raw, name) + if raw[name] ~= nil then return raw[name] end + if raw[name .. "_ac"] ~= nil then return raw[name .. "_ac"] end + local lowered = name:gsub("^L(%d)_", "l%1_") + if lowered ~= name then + if raw[lowered] ~= nil then return raw[lowered] end + if raw[lowered .. "_ac"] ~= nil then return raw[lowered .. "_ac"] end + end + return nil +end + local function emit_meter(data) if type(data) ~= "table" or type(data.meter) ~= "table" then return false end local raw = data.meter - local w = number(raw.W) + local w = number(raw_field(raw, "W")) if w == nil then return false end local meter_reading = { w = w } for _, mapping in ipairs(meter_fields) do - local value = number(raw[mapping[2]]) + local value = number(raw_field(raw, mapping[2])) if value ~= nil then meter_reading[mapping[1]] = value host.emit_metric("meter_" .. mapping[1], value, mapping[3]) diff --git a/drivers/tests/lua_harness/test_zap_meter_schema.lua b/drivers/tests/lua_harness/test_zap_meter_schema.lua new file mode 100644 index 0000000..4dc7e87 --- /dev/null +++ b/drivers/tests/lua_harness/test_zap_meter_schema.lua @@ -0,0 +1,93 @@ +-- 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 + error("schema scenario must be v1 or v2") +end + +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 + +-- The device list is unaffected by the v2 meter-field rename (see +-- https://github.com/srcfl/device-drivers/issues/170 — only `ders[].type` +-- for pv/battery changes, not for the P1 meter), so both scenarios use the +-- same /api/devices response. +respond("/api/devices", [[ +{"devices":[{"sn":"TESTZAP-P1-1","type":"p1_uart","device_type":"energy_meter", + "ders":[{"type":"meter","enabled":true}]}]} +]]) + +if scenario == "v1" then + respond("/api/devices/TESTZAP-P1-1/data/json", [[ +{"meter":{"W":1850,"Hz":50.0, + "L1_W":600,"L1_V":230.1,"L1_A":2.61, + "L2_W":600,"L2_V":230.4,"L2_A":2.60, + "L3_W":650,"L3_V":229.8,"L3_A":2.83, + "total_import_Wh":58000000,"total_export_Wh":9000000}} +]]) +else + respond("/api/devices/TESTZAP-P1-1/data/json", [[ +{"meter":{"W_ac":1850,"Hz":50.0, + "l1_W_ac":600,"l1_V_ac":230.1,"l1_A_ac":2.61, + "l2_W_ac":600,"l2_V_ac":230.4,"l2_A_ac":2.60, + "l3_W_ac":650,"l3_V_ac":229.8,"l3_A_ac":2.83, + "total_import_Wh_ac":58000000,"total_export_Wh_ac":9000000}, + "version":"v2","format":"p1_uart"} +]]) +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") +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") +end + +driver_init({ host = "127.0.0.1" }) +local ok, err = pcall(driver_poll) +if not ok then error("driver_poll failed: " .. tostring(err)) end + +local samples = host._emitted.meter +if not samples or #samples ~= 1 then + error("expected exactly one meter sample for scenario " .. scenario + .. ", got " .. tostring(samples and #samples or 0)) +end + +local meter = samples[1] +local function near(actual, expected) + return type(actual) == "number" and math.abs(actual - expected) < 0.001 +end + +if not near(meter.w, 1850) then error(scenario .. ": meter.w wrong: " .. tostring(meter.w)) end +if not near(meter.l1_w, 600) or not near(meter.l2_w, 600) or not near(meter.l3_w, 650) then + error(scenario .. ": phase power wrong") +end +if not near(meter.l1_v, 230.1) or not near(meter.l2_v, 230.4) or not near(meter.l3_v, 229.8) then + error(scenario .. ": phase voltage wrong") +end +if not near(meter.l1_a, 2.61) or not near(meter.l2_a, 2.60) or not near(meter.l3_a, 2.83) then + error(scenario .. ": phase current wrong") +end +if not near(meter.freq_hz, 50.0) then error(scenario .. ": Hz wrong (unsuffixed in both schemas)") end +if not near(meter.total_import_wh, 58000000) or not near(meter.import_wh, 58000000) then + error(scenario .. ": total_import_Wh / import_wh alias wrong") +end +if not near(meter.total_export_wh, 9000000) or not near(meter.export_wh, 9000000) then + error(scenario .. ": total_export_Wh / export_wh alias wrong") +end + +driver_cleanup() diff --git a/drivers/tests/test_zap_meter_schema.py b/drivers/tests/test_zap_meter_schema.py new file mode 100644 index 0000000..994af2e --- /dev/null +++ b/drivers/tests/test_zap_meter_schema.py @@ -0,0 +1,46 @@ +"""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. +""" + +from pathlib import Path +import re +import subprocess + +import pytest + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_zap_source_has_ftw_read_only_contract(): + source = (ROOT / "drivers/lua/zap.lua").read_text(encoding="utf-8") + for entrypoint in ("driver_init", "driver_poll", "driver_command", + "driver_default_mode"): + assert re.search(rf"\bfunction\s+{entrypoint}\s*\(", source), entrypoint + assert re.search(r"\bhost_api_min\s*=", source) + assert re.search(r"\bhost_api_max\s*=", source) + assert re.search(r"\bread_only\s*=\s*true\b", source) + assert not re.search( + r"\bhost\.(?:http_post|modbus_write(?:_multi)?|mqtt_publish|serial_write)\s*\(", + source, + ), "a read-only driver calls a write-capable host function" + + +@pytest.mark.parametrize("scenario", ["v1", "v2"]) +def test_zap_meter_schema(scenario): + result = subprocess.run( + [ + str(ROOT / "lua55"), + "drivers/tests/lua_harness/test_zap_meter_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 e66f04c..602079c 100644 --- a/index.yaml +++ b/index.yaml @@ -814,14 +814,14 @@ drivers: size_bytes: 2842 sha256: "1c0362e0a25f1fe297de838a85089eb7676b25e78a7d6d1d03ccea1258b94403" - name: "zap" - version: "3.1.1" + version: "3.1.2" tier: core protocol: http connectivity: local ders: [meter, pv, battery] control: false - size_bytes: 26911 - sha256: "dbbef0e614162219c3774292c647f944d0210c752688165afb9a491244700c19" + size_bytes: 28162 + sha256: "5be6d7793a9daa074557182e4333443b382a7a34427dd07143d90f27a8cf01b6" - name: "zaptec" version: "1.0.1" tier: community diff --git a/manifests/zap.yaml b/manifests/zap.yaml index 77aeb9d..4a6e823 100644 --- a/manifests/zap.yaml +++ b/manifests/zap.yaml @@ -1,5 +1,5 @@ name: "zap" -version: "3.1.1" +version: "3.1.2" 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: 26911 +size_bytes: 28162 dkb_id: "zap" -sha256: "dbbef0e614162219c3774292c647f944d0210c752688165afb9a491244700c19" +sha256: "5be6d7793a9daa074557182e4333443b382a7a34427dd07143d90f27a8cf01b6" signature: "" bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index aa17b7b..24ec8f7 100644 --- a/support-status.json +++ b/support-status.json @@ -1850,7 +1850,7 @@ }, { "catalog_source": true, - "catalog_version": "3.1.1", + "catalog_version": "3.1.2", "driver_id": "zap", "targets": { "blixt-l1": {