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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions SUPPORT_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion devices.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
Expand Down
102 changes: 69 additions & 33 deletions drivers/lua/zap.lua
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,25 @@
-- 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,
host_api_max = 1,
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,
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -544,15 +580,15 @@ 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
end

-- 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
Expand All @@ -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

Expand All @@ -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)
Expand Down
11 changes: 2 additions & 9 deletions drivers/tests/lua_harness/test_zap_meter_schema.lua
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading