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..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": "042e0efe8827946de8178845746948f8555b3622", + "commit": "19dd64ccc563d4b1bd102793b066e3f75a83687d", "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.
' + + '' + + '' : '') + '