From 5e7ac82aaa6e8ec7d8ee3ecea62fa6fd4d8254f4 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 23 Aug 2026 18:53:12 +0200 Subject: [PATCH 1/2] feat(zap): opt-in read of PV and battery from Zap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep Zap as the P1/HAN meter by default. Settings → Devices can turn on read-only PV or battery ingest when Zap is the only reader. The driver never writes. Bundled pin moves to srcfl/device-drivers@60021a0 (srcfl/device-drivers#100). Signed-off-by: Fredrik Ahlgren --- .changeset/zap-opt-in-read.md | 5 + config.example.yaml | 9 +- docs/sourceful-zap.md | 28 ++-- drivers/BUNDLED_SOURCE.json | 2 +- .../drivers/catalog_verification_test.go | 9 +- go/internal/drivers/zap_test.go | 132 ++++++++++++++++++ web/settings/tabs/devices.js | 20 ++- web/setup.js | 2 +- web/setup.test.mjs | 16 ++- 9 files changed, 201 insertions(+), 22 deletions(-) create mode 100644 .changeset/zap-opt-in-read.md diff --git a/.changeset/zap-opt-in-read.md b/.changeset/zap-opt-in-read.md new file mode 100644 index 000000000..840b116f0 --- /dev/null +++ b/.changeset/zap-opt-in-read.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +Sourceful Zap can again read PV and battery from devices it already talks to, as an opt-in under Settings → Devices. The default stays P1/HAN only. The driver never writes. diff --git a/config.example.yaml b/config.example.yaml index cb6ec1c14..1eb263a25 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -81,9 +81,10 @@ drivers: # port: 1883 # Sourceful Zap — P1/HAN site meter via the official local API. - # This driver is the meter only. If Zap also lists an inverter, battery - # or charger, add that device in FTW with its own driver. See - # docs/sourceful-zap.md. + # Default is the meter only. If Zap also lists an inverter or battery, + # add that device in FTW with its own driver when you can. Set read_pv + # / read_battery only when Zap is the only reader (closed Modbus, or + # an RS-485 bus Zap already owns). See docs/sourceful-zap.md. # # - name: sourceful-zap # lua: drivers/zap.lua @@ -98,6 +99,8 @@ drivers: # config: # host: zap.local # # meter_serial: p1m-... # optional; P1/HAN is auto-selected + # # read_pv: true # opt-in; default is P1/HAN only + # # read_battery: true # opt-in; telemetry only # NIBE S-series heat pump — read-only telemetry over the on-prem Local # REST API (HTTPS + Basic auth). The pump presents a SELF-SIGNED cert, so diff --git a/docs/sourceful-zap.md b/docs/sourceful-zap.md index 76d649601..ae5605330 100644 --- a/docs/sourceful-zap.md +++ b/docs/sourceful-zap.md @@ -1,9 +1,13 @@ # Sourceful Zap -FTW reads Sourceful Zap as the **P1/HAN site meter**. That is the only -role Zap has in FTW. If Zap also lists an inverter, battery or charger, -add that device in FTW with its own driver. Do not attach those resources -to Zap and pull them in through this integration. +FTW reads Sourceful Zap as the **P1/HAN site meter**. That is the default +role. If Zap also lists an inverter, battery or charger, add that device in +FTW with its own driver when you can. Proxying those devices through an +ESP32 on Wi-Fi is slower and often fights the native protocol. + +Some sites cannot talk to the inverter from FTW: Modbus is closed (no +SetApp), or Zap already owns the RS-485 bus. Then turn on a read under +Settings → Devices. The driver never writes. The driver is telemetry-only. It talks to Zap's local API and stays up without Sourceful cloud. @@ -21,6 +25,8 @@ drivers: allowed_hosts: ["zap.local"] config: host: zap.local + # read_pv: true # opt-in; default is P1/HAN only + # read_battery: true # opt-in; telemetry only ``` The opt-in lets FTW use its own unauthenticated mDNS answer. Use the Zap's LAN @@ -29,14 +35,18 @@ IP in both places when multicast does not cross the network. With several meters, `meter_serial` pins the site meter; otherwise the first P1/HAN device is preferred. +`read_pv` and `read_battery` are off unless you set them. Settings → Devices +exposes the same switches. Leave them off when a native driver already owns +that DER, or Combined will count it twice. + ## Data and identity The driver refreshes Zap devices without restarting and emits the selected meter's power, phases, voltage/current/frequency and energy totals. -If Zap also lists a PV inverter, battery or charger, the driver logs that -and records an `other_resources` metric. It does not ingest those readings. -Add the matching native driver instead. +If Zap also lists a PV inverter, battery or charger that this driver is not +reading, it logs that and records an `other_resources` metric. Add the +matching native driver, or turn on the matching read. The FTW device identity is based on Zap's gateway serial from `/api/crypto`, with a lower-confidence meter serial fallback for older firmware. @@ -51,6 +61,7 @@ zero when a required reading is absent. Silence lets the watchdog and stale-site-meter guard act. `driver_default_mode` performs no write because the driver is read-only. +Commands other than init/deinit fail closed. ## Verification @@ -74,4 +85,5 @@ go test ./internal/drivers -run 'Zap|zap' [docs/operations.md](operations.md); - no meter: inspect Zap's `/api/devices` and pin `meter_serial` when needed; - inverter, battery or charger listed on Zap: add that device in FTW with - its own driver. This Zap driver will not read it. + its own driver, or turn on `read_pv` / `read_battery` when Zap is the only + reader. Chargers have no Zap ingest path. diff --git a/drivers/BUNDLED_SOURCE.json b/drivers/BUNDLED_SOURCE.json index f2a9c9b5a..638b7866a 100644 --- a/drivers/BUNDLED_SOURCE.json +++ b/drivers/BUNDLED_SOURCE.json @@ -17,7 +17,7 @@ "for coverage. Run scripts/sync-bundled-drivers.sh to update." ], "repository": "srcfl/device-drivers", - "commit": "042e0efe8827946de8178845746948f8555b3622", + "commit": "60021a0b074859471ffc95853130d48cb426418a", "source_dir": "drivers/lua", "drivers": [ "ambibox_v2x", "ctek", "ctek_hybrid", "ctek_v2", "deye", "easee_cloud", diff --git a/go/internal/drivers/catalog_verification_test.go b/go/internal/drivers/catalog_verification_test.go index 5a7cababb..302148bf3 100644 --- a/go/internal/drivers/catalog_verification_test.go +++ b/go/internal/drivers/catalog_verification_test.go @@ -57,8 +57,13 @@ func TestCatalogSourcefulZapIsReadOnly(t *testing.T) { if !e.ReadOnly { t.Fatal("Sourceful Zap must remain explicitly read-only until the local API has a semantic control endpoint") } - if len(e.Capabilities) != 1 || e.Capabilities[0] != "meter" { - t.Fatalf("Sourceful Zap capabilities = %v, want [meter] only", e.Capabilities) + want := map[string]bool{"meter": true, "pv": true, "battery": true} + got := map[string]bool{} + for _, c := range e.Capabilities { + got[c] = true + } + if len(got) != len(want) || !got["meter"] || !got["pv"] || !got["battery"] { + t.Fatalf("Sourceful Zap capabilities = %v, want meter, pv, battery (reads stay opt-in)", e.Capabilities) } return } diff --git a/go/internal/drivers/zap_test.go b/go/internal/drivers/zap_test.go index 3d16cab4c..fb41f6335 100644 --- a/go/internal/drivers/zap_test.go +++ b/go/internal/drivers/zap_test.go @@ -170,6 +170,138 @@ func TestZapReadsP1MeterOnly(t *testing.T) { } } +func mixedZapSite() zapAPIStub { + return zapAPIStub{ + crypto: map[string]any{ + "deviceName": "software_zap", + "serialNumber": "zap-04772a97", + "publicKey": "04a1b2c3", + }, + devices: map[string]any{"count": 4, "devices": []any{ + map[string]any{ + "type": "p1_uart", "device_type": "energy_meter", "sn": "p1-main", + "ders": []any{map[string]any{"type": "meter", "enabled": false}}, + }, + map[string]any{ + "type": "modbus_tcp", "device_type": "inverter", "sn": "INV-1", + "ders": []any{ + map[string]any{"type": "pv", "enabled": false, "rated_power": 8000}, + map[string]any{"type": "battery", "enabled": false, "rated_power": 5000, "capacity": 10000}, + }, + }, + map[string]any{ + "type": "modbus_tcp", "device_type": "inverter", "sn": "INV-2", + "ders": []any{map[string]any{"type": "pv", "enabled": false, "rated_power": 6000}}, + }, + map[string]any{ + "type": "mqtt", "device_type": "v2x_charger", "sn": "V2X-1", + "ders": []any{map[string]any{"type": "v2x_charger", "enabled": false, "capacity": 77000}}, + }, + }}, + snapshots: map[string]any{ + "p1-main": map[string]any{"meter": map[string]any{ + "W": -33, "L1_W": 208, "L2_W": -62, "L3_W": -179, + "L1_V": 230.1, "L2_V": 229.9, "L3_V": 230.4, + "L1_A": 1.1, "L2_A": 0.8, "L3_A": 0.9, "Hz": 50.01, + "total_import_Wh": 123456, "total_export_Wh": 65432, + }}, + "INV-1": map[string]any{ + "pv": map[string]any{ + "W": -2500, "rated_power_W": 8000, "total_generation_Wh": 10000, + }, + "battery": map[string]any{ + "W": 500, "rated_power_W": 5000, "SoC_nom_fract": 0.75, + }, + }, + "INV-2": map[string]any{"pv": map[string]any{ + "W": -1250, "rated_power_W": 6000, "total_generation_Wh": 20000, + }}, + "V2X-1": map[string]any{"v2x_charger": map[string]any{ + "W": -3000, "vehicle_soc_fract": 0.60, + }}, + }, + } +} + +func TestZapReadsPVWhenOptedIn(t *testing.T) { + tel, _, _ := loadZapForTest(t, mixedZapSite(), map[string]any{"read_pv": true}) + + meter := tel.Get("sourceful-zap", telemetry.DerMeter) + if meter == nil || meter.RawW != -33 { + t.Fatalf("meter = %+v, want -33W export", meter) + } + + pv := tel.Get("sourceful-zap", telemetry.DerPV) + if pv == nil { + t.Fatal("expected aggregated PV from Zap-listed inverters") + } + if pv.RawW != -3750 { + t.Fatalf("pv = %+v, want -3750W (INV-1 + INV-2)", pv) + } + pvData := readingData(t, pv) + if pvData["rated_w"] != float64(14000) { + t.Fatalf("pv rated_w = %+v, want 14000", pvData["rated_w"]) + } + if pvData["lifetime_wh"] != float64(30000) { + t.Fatalf("pv lifetime_wh = %+v, want 30000", pvData["lifetime_wh"]) + } + + if got := tel.Get("sourceful-zap", telemetry.DerBattery); got != nil { + t.Fatalf("read_pv must not ingest battery: %+v", got) + } + if got := tel.Get("sourceful-zap", telemetry.DerV2X); got != nil { + t.Fatalf("Zap must not ingest V2X: %+v", got) + } + + count, _, ok := tel.LatestMetric("sourceful-zap", "other_resources") + if !ok || count != 2 { + t.Fatalf("other_resources = %v %v, want 2 (battery, charger)", count, ok) + } +} + +func TestZapReadsBatteryWhenOptedIn(t *testing.T) { + tel, _, _ := loadZapForTest(t, mixedZapSite(), map[string]any{"read_battery": true}) + + bat := tel.Get("sourceful-zap", telemetry.DerBattery) + if bat == nil { + t.Fatal("expected battery telemetry from Zap-listed inverter") + } + if bat.RawW != 500 { + t.Fatalf("battery W = %+v, want 500", bat) + } + data := readingData(t, bat) + if data["soc"] != 0.75 { + t.Fatalf("battery soc = %+v, want 0.75 fraction", data["soc"]) + } + + if got := tel.Get("sourceful-zap", telemetry.DerPV); got != nil { + t.Fatalf("read_battery must not ingest PV: %+v", got) + } + if got := tel.Get("sourceful-zap", telemetry.DerV2X); got != nil { + t.Fatalf("Zap must not ingest V2X: %+v", got) + } +} + +func TestZapReadsPVFromInverterOnlySiteWhenOptedIn(t *testing.T) { + stub := zapAPIStub{ + devices: map[string]any{"devices": []any{map[string]any{ + "type": "modbus_tcp", "device_type": "inverter", "sn": "PV-ONLY", + "ders": []any{map[string]any{"type": "pv", "enabled": false, "rated_power": 5000}}, + }}}, + snapshots: map[string]any{"PV-ONLY": map[string]any{"pv": map[string]any{ + "W": -2400, "rated_power_W": 5000, + }}}, + } + tel, _, _ := loadZapForTest(t, stub, map[string]any{"read_pv": true}) + pv := tel.Get("sourceful-zap", telemetry.DerPV) + if pv == nil || pv.RawW != -2400 { + t.Fatalf("inverter-only Zap with read_pv = %+v, want -2400W", pv) + } + if got := tel.Get("sourceful-zap", telemetry.DerMeter); got != nil { + t.Fatalf("unexpected synthetic meter on inverter-only Zap: %+v", got) + } +} + func TestZapDoesNotProxyInverterWithoutMeter(t *testing.T) { stub := zapAPIStub{ devices: map[string]any{"devices": []any{map[string]any{ diff --git a/web/settings/tabs/devices.js b/web/settings/tabs/devices.js index 37318a0ad..6a8cfa4f0 100644 --- a/web/settings/tabs/devices.js +++ b/web/settings/tabs/devices.js @@ -1058,7 +1058,19 @@ '' + '' + ((d.lua || '').indexOf('zap.lua') >= 0 - ? '

This driver is the P1/HAN site meter. If the Zap also lists an inverter, battery or charger, add that device here with its own driver. Do not use Zap as a proxy for those.

' + ? '

This driver is the P1/HAN site meter by default. Prefer a native driver for inverters and batteries. Turn on a read below only when Zap is the only reader — a SolarEdge whose Modbus is closed, or an inverter on RS-485 that Zap already owns. Zap never writes.

' + + '' + + '' : '') + '
' + '' + @@ -1600,7 +1612,8 @@ fillWriteSlot(slot, d, byLua[d.lua], {idx: dIdx, help: help, escHtml: escHtml}); }); bodyEl.querySelectorAll(".drv-disable-pv").forEach(function (lbl) { - var lua = lbl.getAttribute("data-drv-lua"); + var lua = lbl.getAttribute("data-drv-lua") || ""; + if (lua.indexOf("zap.lua") >= 0) return; var entry = lua && byLua[lua]; if (!entry) return; var caps = entry.capabilities || []; @@ -1609,7 +1622,8 @@ } }); bodyEl.querySelectorAll(".drv-disable-battery").forEach(function (lbl) { - var lua = lbl.getAttribute("data-drv-lua"); + var lua = lbl.getAttribute("data-drv-lua") || ""; + if (lua.indexOf("zap.lua") >= 0) return; var entry = lua && byLua[lua]; if (!entry) return; var caps = entry.capabilities || []; diff --git a/web/setup.js b/web/setup.js index 6e65dbe20..0f5153463 100644 --- a/web/setup.js +++ b/web/setup.js @@ -273,7 +273,7 @@ var lines = []; if (selectedCatalog.filename === 'zap.lua' || selectedCatalog.id === 'sourceful-zap') { - lines.push('This driver is the P1/HAN site meter. Add inverters, batteries and chargers as their own devices in FTW. Do not attach them to Zap.'); + lines.push('This driver is the P1/HAN site meter by default. Add inverters, batteries and chargers as their own devices in FTW when you can. If Zap is the only reader, turn on PV or battery ingest later under Settings → Devices.'); } if (selectedCatalog.description) lines.push(selectedCatalog.description); diff --git a/web/setup.test.mjs b/web/setup.test.mjs index a697be478..399d9e414 100644 --- a/web/setup.test.mjs +++ b/web/setup.test.mjs @@ -133,11 +133,19 @@ describe("setup wizard — read-only battery gateways", () => { assert.match(DEVICES_JS, /prevents Combined from counting its power twice/); }); - it("tells the operator that Zap is the P1/HAN meter only", () => { - assert.match(JS, /P1\/HAN site meter/, - "setup must say Zap is the meter, not a proxy for other devices"); + it("tells the operator that Zap is the P1/HAN meter by default", () => { + assert.match(JS, /P1\/HAN site meter by default/, + "setup must say Zap is the meter unless the operator opts in"); assert.match(DEVICES_JS, /class="zap-p1-note"/); - assert.match(DEVICES_JS, /Do not use Zap as a proxy/); + assert.match(DEVICES_JS, /Zap never writes/); + }); + + it("offers opt-in Zap PV and battery reads in Devices", () => { + assert.match(DEVICES_JS, /class="drv-read-pv"/); + assert.match(DEVICES_JS, /drivers\.' \+ idx \+ '\.config\.read_pv/); + assert.match(DEVICES_JS, /class="drv-read-battery"/); + assert.match(DEVICES_JS, /drivers\.' \+ idx \+ '\.config\.read_battery/); + assert.match(DEVICES_JS, /Read PV from devices on this Zap/); }); }); From ce74bdfa4d65f1d9d800a5cbb644d61250ea311d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 24 Aug 2026 10:50:09 +0200 Subject: [PATCH 2/2] chore(drivers): pin Zap 3.1.0 at the device-drivers merge srcfl/device-drivers#100 landed as 19dd64cc. Point the recovery snapshot at that commit instead of the PR head. Signed-off-by: Fredrik Ahlgren --- drivers/BUNDLED_SOURCE.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drivers/BUNDLED_SOURCE.json b/drivers/BUNDLED_SOURCE.json index 638b7866a..5234bfdc9 100644 --- a/drivers/BUNDLED_SOURCE.json +++ b/drivers/BUNDLED_SOURCE.json @@ -17,7 +17,7 @@ "for coverage. Run scripts/sync-bundled-drivers.sh to update." ], "repository": "srcfl/device-drivers", - "commit": "60021a0b074859471ffc95853130d48cb426418a", + "commit": "19dd64ccc563d4b1bd102793b066e3f75a83687d", "source_dir": "drivers/lua", "drivers": [ "ambibox_v2x", "ctek", "ctek_hybrid", "ctek_v2", "deye", "easee_cloud",