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-p1-han-only.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 5 additions & 12 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 19 additions & 34 deletions docs/sourceful-zap.md
Original file line number Diff line number Diff line change
@@ -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

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

Expand All @@ -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.
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": "cf8894c7e17997315e21aa126cab3c7a37d224a1",
"commit": "4fb8608b9835c674e9445a747eea485d77470a64",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Prevent managed Zap v2 from shadowing the new bundle

On a site that previously activated signed Zap 2.x, changing only this bundled pin does not change the running driver: ResolveDriverPaths prefers ManagedDriversDirOverride over the bundled recovery snapshot (go/internal/config/config.go:1297-1327), while repository refresh explicitly never activates a newer version (go/internal/driverrepo/manager.go:51-52). That site therefore continues emitting Zap PV, battery, and V2X data; if the operator follows the new guidance and adds native drivers, those resources are double-counted. Add an upgrade/deactivation path or enforce the P1-only behavior so a pre-3.0 managed Zap cannot shadow this bundle.

AGENTS.md reference: AGENTS.md:L12-L18

Useful? React with 👍 / 👎.

"source_dir": "drivers/lua",
"drivers": [
"ambibox_v2x", "ctek", "ctek_hybrid", "ctek_v2", "deye", "easee_cloud",
Expand Down
4 changes: 2 additions & 2 deletions go/cmd/ftw/capacities_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}},
Expand Down Expand Up @@ -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
Expand Down
5 changes: 2 additions & 3 deletions go/internal/drivers/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions go/internal/drivers/catalog_verification_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
129 changes: 19 additions & 110 deletions go/internal/drivers/zap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand All @@ -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",
Expand All @@ -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)
}
}

Expand All @@ -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{
Expand Down
3 changes: 3 additions & 0 deletions web/settings/tabs/devices.js
Original file line number Diff line number Diff line change
Expand Up @@ -959,6 +959,9 @@
html += '<fieldset><legend>HTTP</legend>' +
'<label>Host / IP ' + help('Hostname (e.g. zap.local) or IP address of the device. mDNS names work when your OS resolver supports them; otherwise use the LAN IP.') + '</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>'
: '') +
'<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 local-API account you set up in the myUplink app.') + '</label>' +
'<input type="text" autocomplete="off" data-path="drivers.' + idx + '.config.username" value="' + escHtml(lcfg.username || '') + '" placeholder="local-api-user">' +
Expand Down
3 changes: 3 additions & 0 deletions web/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading