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
5 changes: 5 additions & 0 deletions .changeset/zap-opt-in-read.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 6 additions & 3 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
28 changes: 20 additions & 8 deletions docs/sourceful-zap.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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

Expand All @@ -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.
2 changes: 1 addition & 1 deletion drivers/BUNDLED_SOURCE.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"for coverage. Run scripts/sync-bundled-drivers.sh to update."
],
"repository": "srcfl/device-drivers",
"commit": "042e0efe8827946de8178845746948f8555b3622",
"commit": "19dd64ccc563d4b1bd102793b066e3f75a83687d",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bundled pin reverts intended commit

High Severity

The bundled pin moves from 60021a0 to 19dd64c, but this PR states the recovery snapshot should use 60021a0 — current device-drivers main plus the Zap 3.1.0 change (and the nibe_local / foxess_h3_smart / myuplink fixes already on main). Restoring the earlier pin can drop that Zap ingest support and those other driver fixes from the image that falls back to this snapshot.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ce74bdf. Configure here.

"source_dir": "drivers/lua",
"drivers": [
"ambibox_v2x", "ctek", "ctek_hybrid", "ctek_v2", "deye", "easee_cloud",
Expand Down
9 changes: 7 additions & 2 deletions go/internal/drivers/catalog_verification_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
132 changes: 132 additions & 0 deletions go/internal/drivers/zap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
20 changes: 17 additions & 3 deletions web/settings/tabs/devices.js
Original file line number Diff line number Diff line change
Expand Up @@ -1058,7 +1058,19 @@
'<label>Host / IP ' + help('Hostname (e.g. zap.local) or IP address of the device. Prefer the device\'s mDNS (.local) name when it broadcasts one — it survives DHCP lease changes. If you use a raw IP, reserve it for the device in your router\'s DHCP settings so it can\'t change.') + '</label>' +
'<input type="text" data-path="drivers.' + idx + '.config.host" value="' + escHtml(lcfg.host || '') + '" placeholder="zap.local">' +
((d.lua || '').indexOf('zap.lua') >= 0
? '<p class="zap-p1-note" style="margin:8px 0 0;font-size:0.82rem;color:var(--text-dim);line-height:1.45">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.</p>'
? '<p class="zap-p1-note" style="margin:8px 0 0;font-size:0.82rem;color:var(--text-dim);line-height:1.45">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.</p>' +
'<label class="drv-read-pv" style="margin-top:8px;display:flex;align-items:center;gap:6px;font-weight:normal">' +
'<input type="checkbox" data-checkbox-path="drivers.' + idx + '.config.read_pv"' +
(lcfg.read_pv ? ' checked' : '') + '>' +
'Read PV from devices on this Zap ' +
help('Pull generation from inverters Zap already talks to. Leave this off when a native inverter driver owns PV, so Combined does not count the same array twice.') +
'</label>' +
'<label class="drv-read-battery" style="margin-top:8px;display:flex;align-items:center;gap:6px;font-weight:normal">' +
'<input type="checkbox" data-checkbox-path="drivers.' + idx + '.config.read_battery"' +
Comment on lines +1062 to +1069

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate ingest switches on the active Zap capability

When an existing site still resolves drivers/zap.lua to a managed or local pre-3.1 driver, these filename-only controls remain visible even though that driver does not implement read_pv or read_battery. Saving either switch therefore succeeds but produces no telemetry, misleading the operator into believing ingest is enabled. The repository deliberately gives managed/local drivers precedence over the newly pinned bundled copy, so render these controls only when the active catalog entry/version declares the corresponding capability.

Useful? React with 👍 / 👎.

(lcfg.read_battery ? ' checked' : '') + '>' +
'Read battery from devices on this Zap ' +
help('Telemetry only. Zap never writes a setpoint. Leave this off when a native battery driver owns the same pack.') +
'</label>'
: '') +
'<div class="drv-local-creds" data-drv-lua="' + escHtml(d.lua || '') + '"' + (localCreds ? '' : ' hidden') + '>' +
'<label style="margin-top:8px">Username ' + help('Username for the device\'s local API (HTTP Basic auth). For NIBE this is the account the pump generates on its own screen when you enable the Local REST API (installer menu 7.5) — no myUplink account and no app are involved.') + '</label>' +
Expand Down Expand Up @@ -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 || [];
Expand All @@ -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 || [];
Expand Down
2 changes: 1 addition & 1 deletion web/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
16 changes: 12 additions & 4 deletions web/setup.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});

Expand Down