From 5d9b97e83f442eaab077b478d6409900bd2d597f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 13 Aug 2026 09:28:04 +0200 Subject: [PATCH 1/2] feat(zap): treat Zap as the P1/HAN meter only Zap must not be a proxy for inverters, batteries or chargers. Setup and Settings now say that, and the tests expect meter-only telemetry. The bundled driver pin still points at the old zap.lua until device-drivers zap/p1-han-meter-only is pushed and the pin moves. BREAKING CHANGE: sites that ingested PV, battery or V2X through Zap lose that path. Add those devices with their own FTW drivers. --- .changeset/zap-p1-han-only.md | 5 + config.example.yaml | 17 +-- docs/sourceful-zap.md | 53 +++---- go/cmd/ftw/capacities_test.go | 4 +- go/internal/drivers/catalog.go | 5 +- .../drivers/catalog_verification_test.go | 3 + go/internal/drivers/zap_test.go | 129 +++--------------- web/settings/tabs/devices.js | 3 + web/setup.js | 3 + web/setup.test.mjs | 7 + 10 files changed, 68 insertions(+), 161 deletions(-) create mode 100644 .changeset/zap-p1-han-only.md diff --git a/.changeset/zap-p1-han-only.md b/.changeset/zap-p1-han-only.md new file mode 100644 index 000000000..9c4fa2f81 --- /dev/null +++ b/.changeset/zap-p1-han-only.md @@ -0,0 +1,5 @@ +--- +"ftw": major +--- + +**BREAKING CHANGE:** Sourceful Zap in FTW is now the P1/HAN site meter only. The driver no longer ingests PV, battery or V2X from devices attached to Zap. If Zap lists an inverter, battery or charger, add that device in FTW with its own driver. Sites that used Zap as a proxy for those resources will lose that telemetry until they do. diff --git a/config.example.yaml b/config.example.yaml index 6ec593cfe..4b6a17f6f 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -77,27 +77,20 @@ drivers: # host: sid-os.local # port: 1883 - # Sourceful Zap — official local-API integration. Zap can be the site meter - # and can aggregate every PV, battery and V2X DER attached to the gateway. - # The driver is telemetry-only: battery_telemetry_only admits battery data - # without making Zap a dispatch target. Zap firmware has semantic local - # command routes, but its REST path currently lacks expiring leases and a - # hardware-execution acknowledgement. FTW control therefore still uses the - # native inverter/charger driver; see docs/sourceful-zap.md. + # 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. # # - name: sourceful-zap # lua: drivers/zap.lua # is_site_meter: true - # battery_telemetry_only: true # capabilities: # http: # allowed_hosts: ["zap.local"] # use the LAN IP if mDNS is unavailable # config: # host: zap.local - # # meter_serial: p1m-... # optional; P1 is auto-selected - # # disable_pv: true # if a native driver already emits PV - # # disable_battery: true # if a native driver emits the battery - # # disable_v2x: true # if a native V2X driver is configured + # # meter_serial: p1m-... # optional; P1/HAN is auto-selected # 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 75f98d600..1fa456b56 100644 --- a/docs/sourceful-zap.md +++ b/docs/sourceful-zap.md @@ -1,7 +1,12 @@ # Sourceful Zap -FTW reads Sourceful Zap through its local API. The integration remains -operational without Sourceful cloud and is currently telemetry-only. +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. + +The driver is telemetry-only. It talks to Zap's local API and stays up +without Sourceful cloud. ## Configuration @@ -10,7 +15,6 @@ drivers: - name: sourceful-zap lua: drivers/zap.lua is_site_meter: true - battery_telemetry_only: true capabilities: http: allowed_hosts: ["zap.local"] @@ -19,51 +23,32 @@ drivers: ``` Use the Zap's LAN IP in both places when mDNS does not cross the network. -`battery_telemetry_only` allows battery display/history without admitting Zap -to the dispatch pool. Do not add `battery_capacity_wh` to this read-only -driver. - -When FTW must control an inverter or charger behind Zap, configure that native -driver and suppress duplicate Zap telemetry: - -```yaml - config: - host: zap.local - disable_pv: true - disable_battery: true - disable_v2x: true -``` With several meters, `meter_serial` pins the site meter; otherwise the first -P1 device is preferred. +P1/HAN device is preferred. ## Data and identity -The driver refreshes Zap devices without restarting and emits: +The driver refreshes Zap devices without restarting and emits the selected +meter's power, phases, voltage/current/frequency and energy totals. -- selected meter power, phases, voltage/current/frequency and energy totals; -- aggregate PV power/energy; -- aggregate battery power/energy and capacity-weighted SoC when possible; -- aggregate V2X power, vehicle SoC, status and diagnostics. +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. -Per-device values remain diagnostic metrics. The FTW device identity is based -on Zap's gateway serial from `/api/crypto`, with a lower-confidence meter -serial fallback for older firmware. +The FTW device identity is based on Zap's gateway serial from `/api/crypto`, +with a lower-confidence meter serial fallback for older firmware. Zap's per-DER `enabled` flag controls its Nova publishing, not whether local -FTW may read the device. +FTW may read the P1/HAN meter. ## Safety The driver converts every value to FTW's site convention and does not invent zero when a required reading is absent. Silence lets the watchdog and -stale-site-meter guard act. Physically impossible values are rejected only -when reported nameplate data provides a quantified bound. +stale-site-meter guard act. `driver_default_mode` performs no write because the driver is read-only. -Zap's current local command responses acknowledge queueing rather than verified -hardware execution and do not provide the expiring, observable command lease -required for unattended dispatch. Native drivers remain the control path. ## Verification @@ -77,5 +62,5 @@ go test ./internal/drivers -run 'Zap|zap' - not found: confirm Zap is on Wi-Fi and reachable at `http://zap.local/api/system` from the FTW host; - no meter: inspect Zap's `/api/devices` and pin `meter_serial` when needed; -- duplicate PV/battery: disable the overlapping Zap DER; -- visible battery is not controlled: expected for the telemetry-only driver. +- inverter, battery or charger listed on Zap: add that device in FTW with + its own driver. This Zap driver will not read it. diff --git a/go/cmd/ftw/capacities_test.go b/go/cmd/ftw/capacities_test.go index 04e4fda70..b15c03298 100644 --- a/go/cmd/ftw/capacities_test.go +++ b/go/cmd/ftw/capacities_test.go @@ -19,7 +19,7 @@ func evCatalog() []drivers.CatalogEntry { {Path: "drivers/easee_cloud.lua", Filename: "easee_cloud.lua", Capabilities: []string{"ev"}}, {Path: "drivers/ctek_hybrid.lua", Filename: "ctek_hybrid.lua", Capabilities: []string{"ev"}}, {Path: "drivers/tesla_vehicle.lua", Filename: "tesla_vehicle.lua", Capabilities: []string{"vehicle"}}, - {Path: "drivers/zap.lua", Filename: "zap.lua", Capabilities: []string{"meter", "pv", "battery", "v2x_charger"}, ReadOnly: true}, + {Path: "drivers/zap.lua", Filename: "zap.lua", Capabilities: []string{"meter"}, ReadOnly: true}, {Path: "drivers/p1meter.lua", Filename: "p1meter.lua", Capabilities: []string{"meter"}}, {Path: "drivers/huawei_battery.lua", Filename: "huawei_battery.lua", Capabilities: []string{"battery"}}, {Path: "drivers/sim_ev.lua", Filename: "sim_ev.lua", Capabilities: []string{"meter"}}, @@ -209,7 +209,7 @@ func TestIsEVOrVehicleDriverByCapabilities(t *testing.T) { {"drivers/sungrow.lua", false}, {"drivers/p1meter.lua", false}, {"drivers/huawei_battery.lua", false}, - {"drivers/zap.lua", false}, // read-only battery gateway, not ev + {"drivers/zap.lua", false}, // read-only P1/HAN meter, not ev // A filename that LOOKS EV-ish but the driver self-declares // otherwise — the previous filename-prefix heuristic would // have over-matched anything starting with "sim_ev", and the diff --git a/go/internal/drivers/catalog.go b/go/internal/drivers/catalog.go index 1514471b5..9bdb7e44e 100644 --- a/go/internal/drivers/catalog.go +++ b/go/internal/drivers/catalog.go @@ -43,8 +43,7 @@ type CatalogEntry struct { // and that POST is not actuation. Only meaningful with ReadOnly. AuthPostPath string `json:"auth_post_path,omitempty"` // ReadOnly means the driver never accepts dispatch commands. The catalog - // UI uses it to avoid presenting battery capacity as a control opt-in and - // to enable battery_telemetry_only for gateway-style drivers. + // UI uses it to avoid presenting battery capacity as a control opt-in. ReadOnly bool `json:"read_only,omitempty"` // ReadOnlyDeclared distinguishes an explicit false value from old metadata // that did not state whether the driver can control hardware. @@ -246,7 +245,7 @@ func IsEVOrVehicleDriver(catalog []CatalogEntry, luaPath string) bool { // IsReadOnlyDriver reports whether the matched Lua catalog entry explicitly // declares read_only=true. Control-pool construction uses this as a safety -// boundary for telemetry gateways such as Sourceful Zap. +// boundary for read-only telemetry drivers such as Sourceful Zap. func IsReadOnlyDriver(catalog []CatalogEntry, luaPath string) bool { if luaPath == "" { return false diff --git a/go/internal/drivers/catalog_verification_test.go b/go/internal/drivers/catalog_verification_test.go index 1e39ba9fa..5a7cababb 100644 --- a/go/internal/drivers/catalog_verification_test.go +++ b/go/internal/drivers/catalog_verification_test.go @@ -57,6 +57,9 @@ 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) + } return } } diff --git a/go/internal/drivers/zap_test.go b/go/internal/drivers/zap_test.go index bfa653c30..3d16cab4c 100644 --- a/go/internal/drivers/zap_test.go +++ b/go/internal/drivers/zap_test.go @@ -84,7 +84,7 @@ func readingData(t *testing.T, reading *telemetry.DerReading) map[string]any { return data } -func TestZapOfficialLocalAPIMapsAllDERs(t *testing.T) { +func TestZapReadsP1MeterOnly(t *testing.T) { stub := zapAPIStub{ crypto: map[string]any{ "deviceName": "software_zap", @@ -122,39 +122,21 @@ func TestZapOfficialLocalAPIMapsAllDERs(t *testing.T) { "INV-1": map[string]any{ "pv": map[string]any{ "W": -2500, "rated_power_W": 8000, "total_generation_Wh": 10000, - "mppt1_V": 410.2, "mppt1_A": -6.1, "heatsink_C": 42.5, }, "battery": map[string]any{ "W": 500, "rated_power_W": 5000, "SoC_nom_fract": 0.75, - "V": 48.2, "A": 10.4, "heatsink_C": 25.0, - "lower_limit_W": -4500, "upper_limit_W": 5000, - "total_charge_Wh": 8000, "total_discharge_Wh": 7200, }, }, "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, "ac_W": -3000, "rated_power_W": 11000, "vehicle_soc_fract": 0.60, - "status": "discharging", "protocol": "ISO_15118_20", "control_mode": "dynamic_bpt", - "connector_status": "occupied", "charging_state": "discharging", - "plug_connected": true, "V": 230.5, "A": -13.0, "Hz": 49.98, - "L1_V": 232.8, "L1_A": -13.0, "L1_W": -3026, - "L2_V": 230.9, "L2_A": 0.0, "L2_W": 0, - "L3_V": 230.2, "L3_A": 0.0, "L3_W": 0, - "dc_W": -3150, "dc_V": 400, "dc_A": -7.875, - "ev_target_energy_req_Wh": 5300, "ev_max_energy_req_Wh": 18100, - "ev_min_energy_req_Wh": -25800, "session_charge_Wh": 50, - "session_discharge_Wh": 1250, "total_charge_Wh": 142000, - "total_discharge_Wh": 5100, + "W": -3000, "vehicle_soc_fract": 0.60, }}, }, } tel, env, _ := loadZapForTest(t, stub, nil) - if !env.BatteryTelemetryOnly { - t.Fatal("Zap read_only catalog metadata must automatically admit battery telemetry") - } makeName, serial := env.Identity() if makeName != "Sourceful" || serial != "zap-04772a97" { t.Fatalf("identity = %q/%q, want Sourceful/zap-04772a97", makeName, serial) @@ -172,61 +154,23 @@ func TestZapOfficialLocalAPIMapsAllDERs(t *testing.T) { t.Fatalf("meter energy aliases = %+v", meterData) } - pv := tel.Get("sourceful-zap", telemetry.DerPV) - if pv == nil || pv.RawW != -3750 { - t.Fatalf("PV = %+v, want aggregate -3750W", pv) - } - pvData := readingData(t, pv) - if pvData["lifetime_wh"] != float64(30000) || pvData["total_generation_wh"] != float64(30000) { - t.Fatalf("PV lifetime = %+v, want 30000Wh", pvData) - } - if pvData["rated_power_w"] != float64(14000) { - t.Fatalf("PV aggregate rating = %v, want 14000W", pvData["rated_power_w"]) - } - - battery := tel.Get("sourceful-zap", telemetry.DerBattery) - if battery == nil || battery.RawW != 500 || battery.SoC == nil || *battery.SoC != 0.75 { - t.Fatalf("battery = %+v, want +500W charge at 75%%", battery) - } - batteryData := readingData(t, battery) - if batteryData["discharge_capable"] != true || batteryData["charge_capable"] != true { - t.Fatalf("battery capability mapping = %+v", batteryData) - } - if batteryData["capacity_wh"] != float64(10000) || batteryData["total_charge_wh"] != float64(8000) { - t.Fatalf("battery capacity/energy mapping = %+v", batteryData) - } - - v2x := tel.Get("sourceful-zap", telemetry.DerV2X) - if v2x == nil || v2x.RawW != -3000 || v2x.SoC == nil || *v2x.SoC != 0.60 { - t.Fatalf("V2X = %+v, want -3000W at 60%%", v2x) - } - v2xData := readingData(t, v2x) - if v2xData["connected"] != true || v2xData["dc_w"] != float64(-3150) { - t.Fatalf("V2X mapping = %+v", v2xData) + if got := tel.Get("sourceful-zap", telemetry.DerPV); got != nil { + t.Fatalf("Zap must not ingest PV from attached inverters: %+v", got) } - if v2xData["protocol"] != "ISO_15118_20" || v2xData["control_mode"] != "dynamic_bpt" || - v2xData["connector_status"] != "occupied" || v2xData["charging_state"] != "discharging" || - v2xData["ac_w"] != float64(-3000) || v2xData["ac_v"] != 230.5 || - v2xData["l1_w"] != float64(-3026) { - t.Fatalf("V2X electrical/protocol mapping = %+v", v2xData) + if got := tel.Get("sourceful-zap", telemetry.DerBattery); got != nil { + t.Fatalf("Zap must not ingest battery from attached inverters: %+v", got) } - if v2xData["ev_target_energy_req_wh"] != float64(5300) || - v2xData["ev_min_energy_req_wh"] != float64(-25800) || - v2xData["capacity_wh"] != float64(77000) { - t.Fatalf("V2X energy/capacity mapping = %+v", v2xData) + if got := tel.Get("sourceful-zap", telemetry.DerV2X); got != nil { + t.Fatalf("Zap must not ingest V2X from attached chargers: %+v", got) } - // Zap's `enabled` flag is a Novacore publish switch, not a local-read - // switch. All four DER kinds above deliberately use enabled=false. - if _, _, ok := tel.LatestMetric("sourceful-zap", "pv_w_inv_1"); !ok { - t.Fatal("expected per-inverter PV diagnostic for multi-PV Zap") - } - if value, _, ok := tel.LatestMetric("sourceful-zap", "v2x_ev_min_energy_req_wh"); !ok || value != -25800 { - t.Fatalf("V2X signed energy diagnostic = %v %v, want -25800 Wh", value, ok) + count, _, ok := tel.LatestMetric("sourceful-zap", "other_resources") + if !ok || count != 3 { + t.Fatalf("other_resources = %v %v, want 3 (PV, battery, charger)", count, ok) } } -func TestZapPVOnlyWorksWithoutP1Meter(t *testing.T) { +func TestZapDoesNotProxyInverterWithoutMeter(t *testing.T) { stub := zapAPIStub{ devices: map[string]any{"devices": []any{map[string]any{ "type": "modbus_tcp", "device_type": "inverter", "sn": "PV-ONLY", @@ -237,11 +181,15 @@ func TestZapPVOnlyWorksWithoutP1Meter(t *testing.T) { }}}, } tel, _, _ := loadZapForTest(t, stub, nil) - if got := tel.Get("sourceful-zap", telemetry.DerPV); got == nil || got.RawW != -2400 { - t.Fatalf("PV-only Zap reading = %+v, want -2400W", got) + if got := tel.Get("sourceful-zap", telemetry.DerPV); got != nil { + t.Fatalf("inverter-only Zap must not emit PV: %+v", got) } if got := tel.Get("sourceful-zap", telemetry.DerMeter); got != nil { - t.Fatalf("unexpected synthetic meter on PV-only Zap: %+v", got) + t.Fatalf("unexpected synthetic meter on inverter-only Zap: %+v", got) + } + count, _, ok := tel.LatestMetric("sourceful-zap", "other_resources") + if !ok || count != 1 { + t.Fatalf("other_resources = %v %v, want 1", count, ok) } } @@ -260,45 +208,6 @@ func TestZapDoesNotInventZeroForMissingRequiredPower(t *testing.T) { } } -func TestZapRejectsPowerOverflowAgainstNameplate(t *testing.T) { - stub := zapAPIStub{ - devices: map[string]any{"devices": []any{map[string]any{ - "type": "modbus_tcp", "device_type": "inverter", "sn": "INV-OFFLINE", - "ders": []any{map[string]any{"type": "pv", "rated_power": 5000}}, - }}}, - snapshots: map[string]any{"INV-OFFLINE": map[string]any{"pv": map[string]any{ - "W": -65535, "rated_power_W": 5000, - }}}, - } - tel, _, _ := loadZapForTest(t, stub, nil) - if got := tel.Get("sourceful-zap", telemetry.DerPV); got != nil { - t.Fatalf("overflow sentinel must not reach site PV: %+v", got) - } -} - -func TestZapDisableFlagsAvoidDuplicateNativeDrivers(t *testing.T) { - stub := zapAPIStub{ - devices: map[string]any{"devices": []any{map[string]any{ - "type": "modbus_tcp", "device_type": "inverter", "sn": "HYBRID", - "ders": []any{ - map[string]any{"type": "pv", "rated_power": 5000}, - map[string]any{"type": "battery", "rated_power": 5000, "capacity": 10000}, - }, - }}}, - snapshots: map[string]any{"HYBRID": map[string]any{ - "pv": map[string]any{"W": -1000, "rated_power_W": 5000}, - "battery": map[string]any{"W": 500, "rated_power_W": 5000, "SoC_nom_fract": 0.5}, - }}, - } - tel, _, _ := loadZapForTest(t, stub, map[string]any{"disable_pv": true, "disable_battery": true}) - if got := tel.Get("sourceful-zap", telemetry.DerPV); got != nil { - t.Fatalf("disable_pv still emitted %+v", got) - } - if got := tel.Get("sourceful-zap", telemetry.DerBattery); got != nil { - t.Fatalf("disable_battery still emitted %+v", got) - } -} - func TestZapDoesNotUseUnsafeLegacyRESTControl(t *testing.T) { var posts atomic.Int32 stub := zapAPIStub{ diff --git a/web/settings/tabs/devices.js b/web/settings/tabs/devices.js index c1e6f3b8f..5593c81c7 100644 --- a/web/settings/tabs/devices.js +++ b/web/settings/tabs/devices.js @@ -959,6 +959,9 @@ html += '
HTTP' + '' + '' + + ((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.

' + : '') + '
' + '' + '' + diff --git a/web/setup.js b/web/setup.js index b5e4f9562..4af73d094 100644 --- a/web/setup.js +++ b/web/setup.js @@ -266,6 +266,9 @@ btn.disabled = false; 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.'); + } if (selectedCatalog.description) lines.push(selectedCatalog.description); var version = selectedCatalog.installed_version || selectedCatalog.version; diff --git a/web/setup.test.mjs b/web/setup.test.mjs index b5da5a439..b935bdd18 100644 --- a/web/setup.test.mjs +++ b/web/setup.test.mjs @@ -132,6 +132,13 @@ describe("setup wizard — read-only battery gateways", () => { assert.match(DEVICES_JS, /caps\.indexOf\("meter"\) >= 0 && caps\.indexOf\("battery"\) >= 0/); 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"); + assert.match(DEVICES_JS, /class="zap-p1-note"/); + assert.match(DEVICES_JS, /Do not use Zap as a proxy/); + }); }); describe("price provider defaults", () => { From ead8058daef63c3eb4fc414d72c50d395d14e8ce Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 13 Aug 2026 09:34:10 +0200 Subject: [PATCH 2/2] chore(drivers): pin bundled Zap to P1/HAN-only 3.0.0 Move the device-drivers pin to 4fb8608 (#82) so the offline recovery copy of zap.lua is the meter-only driver. --- 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 118dd9e52..51da33e1f 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": "cf8894c7e17997315e21aa126cab3c7a37d224a1", + "commit": "4fb8608b9835c674e9445a747eea485d77470a64", "source_dir": "drivers/lua", "drivers": [ "ambibox_v2x", "ctek", "ctek_hybrid", "ctek_v2", "deye", "easee_cloud",