From 1a69e648f55eccec474b2005e41dc2d0ec93b410 Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Wed, 29 Jul 2026 15:44:57 +0200 Subject: [PATCH] feat(nibe_local): add opt-in Solar PV surplus write path (srcfl/ftw#537) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NIBE local driver gains its first write path, and deliberately its only one: the pump's native Solar PV surplus feed. The S-series was built to take a live "available solar power" number from NIBE's Modbus accessory (registers 2107 enable / 2109 available power) and soak the surplus into heating and hot water using owner-tuned offsets. FTW now acts as that accessory. Control by hint — the pump's firmware decides what to do with the number, so a wrong value degrades to wasted comfort, never to unsafe operation. Off by default and triple-gated: the host `capabilities.http.allow_write` grant, the driver's `write.solar_pv: true` with a mandatory `write.max_w` clamp ceiling, and the owner-side enable on the pump itself (register 2107). Local-API variableIds are resolved from each point's modbusRegisterID at poll time, so 2107/2109 are never hard-coded. Safety posture (each clamp answers a quantified risk): - value clamped to [0, max_w] so a sign bug or telemetry spike cannot tell the pump there is 100 kW of surplus; - deadband + at-most-one-write-per-interval against register churn; a decrease past the deadband is written immediately (surplus collapsed = safety direction); - dead-man's switch clears the feed when commands stop — the pump's timeout for a silently stopped feed is undocumented, so the driver does not lean on it; - driver_default_mode clears the feed on watchdog trip / stale site meter / driver stop, rate-limited and idempotent; - a startup sweep clears a non-zero feed a crashed run left standing; - the clearing machinery stays armed even when a config mistake (missing max_w) refuses NEW writes, so a mistake never strands a standing feed. The Local REST API's most dangerous habit is covered: it rejects a write *inside* an HTTP 200 with a per-point "error: read only value" result. The driver judges success on the body, never the status code, and surfaces a read-only pump as an actionable error naming the installer menu (7.5.15). Requires `host.http_patch` from the FTW core; on older cores the driver says so and stays read-only. The header documents the decommission step (turn 2107 off, or set the API read-only) for the day FTW is no longer there to clear the feed. Editing this promoted driver drops its byte-identity exemption from baselines/ftw, so the full catalog convention suite now applies to it. Brought it into compliance without changing behaviour: - wrapped the read path's http_get and json_decode in pcall; - lowercased the three mutable interval locals so driver_poll's returns match the poll-interval convention (they are reassigned from config, so lowercase is the honest style; genuine constants stay UPPERCASE); - renamed resolve_pv_registers -> resolve_pv_points: the `_registers` suffix made test_no_undefined_locals read a declared local function as an undeclared register guard. authors is set to "Claude Code (with the help of HuggeK)" per request. Tests: a fake-pump Lua harness (test_nibe_local_ftw.lua) exercising the clamp, deadband, sign conversion, pump-side gates, dead-man's switch, orphan clear, sentinel skip and default-mode idempotence; host_mock gains an http_patch mock; spec/host-api-profile.json declares http_patch. Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com> Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> --- CHANGELOG.md | 3 + SUPPORT_STATUS.md | 4 +- devices.yaml | 4 +- drivers/lua/nibe_local.lua | 377 ++++++++++++++++-- drivers/tests/lua_harness/host_mock.lua | 38 ++ .../tests/lua_harness/test_nibe_local_ftw.lua | 257 ++++++++++++ drivers/tests/test_http_drivers.py | 34 +- drivers/tests/test_nibe_local.py | 32 ++ index.yaml | 6 +- manifests/nibe_local.yaml | 8 +- spec/host-api-profile.json | 2 + support-status.json | 2 +- 12 files changed, 716 insertions(+), 51 deletions(-) create mode 100644 drivers/tests/lua_harness/test_nibe_local_ftw.lua create mode 100644 drivers/tests/test_nibe_local.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e9191e..0cc0e79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ Driver versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html ## [Unreleased] +### Added +- **nibe_local** 1.2.0 — the driver's first write path, and deliberately its only one: the pump's native **Solar PV surplus feed** ([srcfl/ftw#537](https://github.com/srcfl/ftw/issues/537)). The S-series was built to take a live "available solar power" number from NIBE's Modbus accessory (registers 2107/2109) and soak the surplus into heating and hot water using owner-tuned offsets; FTW now acts as that accessory. Control-by-hint: the pump's firmware decides what to do with the number, so a wrong value degrades to wasted comfort, never to unsafe operation. Off by default and triple-gated — host `capabilities.http.allow_write`, driver `write.solar_pv: true` with a mandatory `write.max_w` clamp ceiling, and the owner-side enable on the pump itself. The pump's timeout for a silently stopped feed is undocumented, so the driver does not lean on it: a dead-man's switch clears the feed when commands stop, `driver_default_mode` clears it on watchdog/stale-meter/stop, and a startup sweep clears a feed a crashed run left behind — and the sweep stays armed even when a config mistake (missing `max_w`) refuses new writes. All of that runs only while FTW runs, so the driver header documents the decommission step (turn 2107 off, or set the API read-only in menu 7.5.15) for the day it does not. The write API's most dangerous habit is covered too: the Local REST API rejects writes *inside an HTTP 200* ("error: read only value"), which the driver surfaces as an actionable error naming the installer menu (7.5.15) instead of reporting success. Requires `host.http_patch` from the FTW core; on older cores the driver states so and stays read-only + ### Fixed - **sungrow** 1.5.6 — **the zero-power refusal is restored. 1.5.4 was wrong, on a premise that did not survive reading the driver.** The case for waving zero through was that it hands the device back to itself, so refusing it could strand an inverter in a forced state. Neither half holds. `set_battery_idle` writes EMS mode **2** — forced — with command `0xCC` and setpoint 0: it pins the battery at zero under the host's control rather than releasing it. The release is `driver_default_mode`, which FTW reaches through `SendDefault` on shutdown, lease expiry and the watchdog, never through `driver_command`, and which no guard touches. So refusing withholds nothing that is not reachable by a route that cannot refuse — while accepting put mode 2 and a setpoint back on an SG inverter that implements neither, and reported success, because the read-back that would catch it fails on that device too. That is the SG12RT bug through the one action nobody guarded - The two sungrow copies disagree about what zero writes — the catalog driver forces idle at mode 2, the FTW v2 target hands the inverter back at mode 0 and clears the lease — and they use different register maps throughout. That belongs to the register-map review `packages/v1/sungrow/PILOT.md` already requires before the v2 target ships; its `verification_status` is `experimental` and the signed package builds from `targets/ftw-observe.lua`, so nothing there reaches hardware today. Recorded because untested drift between these two files is how Pixii's 40288 came back diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index c577b4b..b67142a 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -86,8 +86,8 @@ Catalog source is not proof that a target can install or run a driver. | mennekes | 1.0.3 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | | myuplink | 1.1.0 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | | myuplink | 1.1.0 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| nibe_local | 1.1.0 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| nibe_local | 1.1.0 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | +| nibe_local | 1.2.0 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | +| nibe_local | 1.2.0 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | | opendtu | 1.0.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | | opendtu | 1.0.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | | opendtu_mqtt | 1.0.3 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | diff --git a/devices.yaml b/devices.yaml index a2d101b..8fbadb1 100644 --- a/devices.yaml +++ b/devices.yaml @@ -989,11 +989,11 @@ manufacturers: protocols: - protocol: http driver: "nibe_local" - version: "1.1.0" + version: "1.2.0" ders: [meter] control: true firmware_versions: "" - notes: "Read-only NIBE S-series heat-pump telemetry over the on-prem Local REST API (HTTPS + Basic auth, self-signed cert pinned via tls_pin_sha256). Emits compressor/used power, lifetime energy meters, and the full ~980-point register map. Observe-only — no control." + notes: "NIBE S-series heat-pump telemetry over the on-prem Local REST API (HTTPS + Basic auth, self-signed cert pinned via tls_pin_sha256). Emits compressor/used power, lifetime energy meters, and the full ~980-point register map. Read-only by default; opt-in write path feeds the pump's native Solar PV surplus input (2107/2109)." - name: "OpenEVSE" model_families: - name: "OpenEVSE" diff --git a/drivers/lua/nibe_local.lua b/drivers/lua/nibe_local.lua index fb25464..9b04904 100644 --- a/drivers/lua/nibe_local.lua +++ b/drivers/lua/nibe_local.lua @@ -1,6 +1,8 @@ --- NIBE S-series Heat Pump — LOCAL REST API driver (READ-ONLY telemetry) +-- NIBE S-series Heat Pump — LOCAL REST API driver -- Emits: metrics only (compressor power, energy meters, temperatures, …) --- into the long-format TS DB via host.emit_metric. NO control. +-- into the long-format TS DB via host.emit_metric. +-- Control: one opt-in write path — the pump's native "Solar PV" surplus feed +-- (srcfl/ftw#537). Everything else on the pump stays untouchable. -- Protocol: HTTPS (NIBE "Local REST API", self-described at https://:8443/) -- -- This is the local-network twin of drivers/myuplink.lua. Instead of the @@ -11,14 +13,54 @@ -- one bulk GET. Headline metrics land every minute; the bulk map records -- changes plus an hourly full snapshot so the TS DB stays bounded on a Pi. -- --- Observe-only by design: the pump is left in read-only mode (aidMode=off), --- so this driver cannot actuate anything and cannot cause harm. +-- READ-ONLY BY DEFAULT. Without `write.solar_pv: true` in the driver config +-- the driver never issues a write. Note the write gate on the pump side is +-- the installer's read-only / read-write choice for the Local REST API +-- (installer menu 7.5.15) — NOT "aid mode", which is the pump's +-- compressor-off fault-recovery state and has nothing to do with API +-- permissions. A pump left read-only silently refuses writes (HTTP 200 with +-- a per-point "error: read only value" result), and this driver surfaces +-- that as an actionable error instead of pretending success. +-- +-- THE SOLAR PV FEED (the only write this driver performs): +-- The S-series has a built-in "Solar PV" input meant for NIBE's Modbus +-- TCP/IP accessory: when the owner enables it (register 2107), the pump +-- expects an external box to keep "Available power" (register 2109) +-- updated with the current solar surplus, and applies the owner-tuned +-- Offset heating/cooling/pool setpoint shifts to soak it up. FTW acts as +-- that accessory: control-by-hint, not direct actuation — the pump's own +-- firmware decides what to do with the number, so misbehaviour degrades to +-- "pump believes a wrong solar value", never to unsafe operation. +-- The semantic switch 2108 ("include own consumption") and the offset +-- aggressiveness stay owner-tuned on the pump; this driver never writes +-- them. +-- +-- Safety posture (each clamp answers a quantified risk): +-- - Value clamped to [0, write.max_w] — a sign bug or telemetry spike must +-- not tell the pump there are 100 kW of surplus. +-- - Deadband + at-most-one-write-per-interval — register wear and pointless +-- churn; a heat pump reacts over minutes, not seconds. +-- - Dead-man's switch: if no fresh solar_pv command lands within +-- write.ttl_s, the driver writes 0 once and stops. The pump-side timeout +-- for a silent feed is UNDOCUMENTED, so we do not rely on it. +-- - driver_default_mode (watchdog trip, stale site meter, driver stop) +-- clears the feed: absence of writes is the safe state. +-- - On startup the driver clears a non-zero feed it does not remember +-- writing (crash/restart orphan) — same undocumented-timeout reasoning. +-- All of these run only while FTW itself runs. DECOMMISSIONING: before +-- uninstalling FTW or permanently disabling this feed, turn the Solar PV +-- input (2107) off on the pump — or set the Local REST API back to +-- read-only (menu 7.5.15) — so no stale surplus value can stand with +-- nobody left to clear it. -- -- Site sign convention: a heat pump is a LOAD. Its electrical draw would be -- positive W flowing into the site at the grid boundary — but this driver -- emits diagnostics via host.emit_metric only (never host.emit("meter"|…)), -- so it performs NO sign conversion and never double-counts against the real -- grid meter. The thermal/load models consume hp_power_w etc. as twins. +-- The solar_pv command arrives in site convention (negative W = surplus +-- leaving the site) and is converted HERE to the pump's positive-W value — +-- sign conversion happens only at the driver boundary. -- -- AUTH + TRANSPORT: -- The local API uses HTTP Basic auth over HTTPS with a SELF-SIGNED @@ -41,10 +83,19 @@ -- username: "" -- password: "" # masked via config_secrets -- # device_id: "..." # optional; auto-detected if omitted +-- # write: # opt-in Solar PV feed (srcfl/ftw#537) +-- # solar_pv: true # master switch; default off +-- # max_w: 9000 # REQUIRED: site PV nameplate, clamp ceiling +-- # deadband_w: 50 # skip writes changing less than this +-- # ttl_s: 300 # dead-man: clear feed if commands stop +-- # min_interval_ms: 60000 # rate limit for nonzero increases +-- # enable_id: "5201" # variableId override for register 2107 +-- # available_id: "5202" # variableId override for register 2109 -- capabilities: -- http: -- allowed_hosts: ["192.168.1.180:8443"] -- tls_pin_sha256: "<64-hex-char certificate fingerprint>" +-- # allow_write: true # host-enforced write gate; default off -- -- The four heating-UI headline metrics map to NIBE S735 variable ids by -- default; override per model via param_power_id / param_hw_temp_id / @@ -57,12 +108,12 @@ DRIVER = { id = "nibe-local", name = "NIBE REST API S-series", manufacturer = "NIBE", - version = "1.0.0", + version = "1.2.0", protocols = { "http" }, capabilities = { "apicreds" }, - description = "Read-only NIBE S-series heat-pump telemetry over the on-prem Local REST API (HTTPS + Basic auth, self-signed cert pinned via tls_pin_sha256). Emits compressor/used power, lifetime energy meters, and the full ~980-point register map. Observe-only — no control.", + description = "NIBE S-series heat-pump telemetry over the on-prem Local REST API (HTTPS + Basic auth, self-signed cert pinned via tls_pin_sha256). Emits compressor/used power, lifetime energy meters, and the full ~980-point register map. Read-only by default; one opt-in write path feeds the pump's native Solar PV surplus input (registers 2107/2109) so the pump soaks up excess solar.", homepage = "https://www.nibe.eu", - authors = { "HuggeK", "FTW contributors" }, + authors = { "Claude Code (with the help of HuggeK)" }, tested_models = { "NIBE S735" }, verification_status = "beta", config_secrets = { "password" }, @@ -80,16 +131,32 @@ local serial = nil -- device id (NIBE serial number) used in the path -- Self-heal: the pump can be briefly unreachable at boot / after a network -- blip. Rather than wedge on a nil serial (which needed a manual restart), -- driver_poll retries device detection on this backoff. -local SETUP_RETRY_MS = 30000 +local setup_retry_ms = 30000 local last_setup_ms = nil -local POLL_INTERVAL_MS = 60000 +local poll_interval_ms = 60000 -- Headline metrics are emitted every poll. The remaining ~980-point map is -- change-only, with an hourly full refresh so latest-value timestamps stay -- useful without writing ~1.4 million mostly-duplicate TS rows per day. -local FULL_REFRESH_MS = 3600000 +local full_refresh_ms = 3600000 local last_full_emit_ms = nil local last_emitted = {} +-- ---- Solar PV feed state (the one write path — srcfl/ftw#537) ------------ +-- The pump's "Solar PV" input, by S-series Modbus register id. The Local +-- REST API keys points by its own variableId; every point's metadata carries +-- modbusRegisterID, so we resolve variableIds at poll time and never +-- hard-code them (they differ between models; the registers don't). +local SOLAR_PV_ENABLE_REG = 2107 -- "Modbus TCP/IP Ext. (Solar PV)" — owner-enabled master switch +local SOLAR_PV_AVAILABLE_REG = 2109 -- "Available power" — the value FTW feeds (u16 W) + +local write_cfg = { solar_pv = false } +local pv_reg = nil -- { enable={id,value}, avail={id,divisor,raw} } resolved from the bulk GET +local feed_last_w = nil -- last surplus W this run successfully wrote (nil = never wrote) +local feed_cmd_ms = nil -- host.millis() of the last solar_pv command received +local feed_write_ms = nil -- host.millis() of the last successful PATCH +local orphan_checked = false -- startup clear of a feed left behind by a previous run +local default_clear_ms = nil -- rate limit for default-mode clears (fires every tick on stale site meter) + -- ---- Headline metrics + per-model profiles ------------------------------- -- The BULK of telemetry is metadata-driven (every point self-describes its -- unit + divisor), so reading any S-series pump needs NO per-model code. The @@ -248,13 +315,205 @@ local function auth_headers() end local function api_get(path) - local resp, err = host.http_get(base_url .. path, auth_headers()) + -- pcall both host calls: a malformed response or a host-side raise must + -- surface as a poll error the caller can back off on, never an + -- unprotected error that kills driver_poll. + local ok, resp, err = pcall(host.http_get, base_url .. path, auth_headers()) + if not ok then return nil, "http_get error: " .. tostring(resp) end if err then return nil, tostring(err) end - local data = host.json_decode(resp) - if not data then return nil, "json decode failed" end + local ok_json, data = pcall(host.json_decode, resp) + if not ok_json or not data then return nil, "json decode failed" end return data, nil end +-- ---- Solar PV feed (write path) ------------------------------------------ + +-- Find one point in the bulk GET result: by explicit variableId override +-- when configured, else by its Modbus register id from point metadata. +local function find_point(data, var_id, modbus_reg) + if var_id then + local pt = data[tostring(var_id)] + if pt then return tostring(var_id), pt end + return nil, nil + end + for id, pt in pairs(data) do + local m = pt and pt.metadata + if type(m) == "table" and tonumber(m.modbusRegisterID) == modbus_reg then + return tostring(id), pt + end + end + return nil, nil +end + +-- Resolve the Solar PV enable + available-power points from a bulk GET. +-- Called every poll while the feed is requested, so the pump-side enable +-- state (2107) stays current without extra requests. Last-known-good ids +-- are RETAINED when a decodable body happens to lack the points (partial +-- body, firmware hiccup): the ability to clear a standing feed must not +-- vanish exactly when the pump misbehaves. +local function resolve_pv_points(data) + local eid, ept = find_point(data, write_cfg.enable_id, SOLAR_PV_ENABLE_REG) + local aid, apt = find_point(data, write_cfg.available_id, SOLAR_PV_AVAILABLE_REG) + pv_reg = pv_reg or {} + if eid and ept then + pv_reg.enable = { id = eid, value = ept.value and ept.value.integerValue } + end + if aid and apt then + local m = apt.metadata or {} + local div = tonumber(m.divisor) or 1 + if div == 0 then div = 1 end + pv_reg.avail = { id = aid, divisor = div, size = m.variableSize, + raw = apt.value and apt.value.integerValue } + end +end + +-- One PATCH of the available-power point. The pump answers HTTP 200 even +-- when it refuses a write — the per-point result string ("modified" / +-- "error: read only value" / "error: no such param") is the real verdict, +-- so success is judged on the body, never on the status code alone. +local function write_pv_surplus(w, why) + if not host.http_patch then + return nil, "host.http_patch unavailable — this FTW core predates HTTP writes" + end + if not (pv_reg and pv_reg.avail and serial) then + return nil, "Solar PV registers not resolved yet (no successful poll)" + end + local raw = math.floor(w * pv_reg.avail.divisor + 0.5) + -- 2109 is a u16 point and 65535 is the not-connected sentinel; stay + -- comfortably below both. write.max_w already clamped the wattage. + if raw > 65000 then raw = 65000 end + if raw < 0 then raw = 0 end + local body = string.format( + '[{"type":"datavalue","variableId":%d,"integerValue":%d,"stringValue":""}]', + tonumber(pv_reg.avail.id), raw) + local hdrs = auth_headers() + hdrs["Content-Type"] = "application/json" + local resp, err = host.http_patch( + base_url .. "/api/v1/devices/" .. serial .. "/points", body, hdrs) + if err then return nil, "PATCH failed: " .. tostring(err) end + resp = tostring(resp or "") + if string.find(resp, "read only", 1, true) then + return nil, "pump refused the write — its Local REST API is read-only; " .. + "switch it to read/write on the pump (installer menu 7.5.15)" + end + if string.find(resp, "error", 1, true) then + return nil, "pump rejected the write: " .. string.sub(resp, 1, 200) + end + if not string.find(resp, "modified", 1, true) then + -- Unknown-but-not-an-error response shape: accept, keep evidence. + host.log("debug", "NIBE: unexpected PATCH response: " .. string.sub(resp, 1, 200)) + end + feed_last_w = w + feed_write_ms = host.millis() + host.emit_metric("hp_solar_pv_feed_w", w, "W", + tostring(SOLAR_PV_AVAILABLE_REG), "Solar PV feed (FTW)") + host.log("info", "NIBE: solar PV feed = " .. tostring(w) .. " W (" .. why .. ")") + return true, nil +end + +-- The driver_command("solar_pv", power_w) implementation. power_w is in +-- site convention: negative W = power leaving the site = exportable surplus. +-- Returns true on success/no-op, or an error string (v1 contract). +local function solar_pv_command(power_w) + if not write_cfg.solar_pv then + return "solar_pv: " .. (write_cfg.disabled_reason or + "writes are disabled (set driver config write.solar_pv: true)") + end + if not serial then + return "solar_pv: pump not detected yet" + end + if not host.http_patch then + return "solar_pv: host.http_patch unavailable — FTW core upgrade required" + end + if not (pv_reg and pv_reg.avail) then + return "solar_pv: Solar PV registers not resolved yet (waiting for first poll)" + end + local n = tonumber(power_w) + if n == nil then + return "solar_pv: power_w missing" + end + -- Site convention → pump value: only export (negative) is surplus. + local surplus = n < 0 and -n or 0 + if surplus > write_cfg.max_w then surplus = write_cfg.max_w end + surplus = math.floor(surplus + 0.5) + + feed_cmd_ms = host.millis() -- any fresh command feeds the dead-man's switch + + -- A clear is always safe: it bypasses the pump-side enable gate (the + -- owner turning 2107 off mid-feed must not block FTW from zeroing what + -- it wrote) and every rate limit — dropping a clear to save a request + -- would invert the safety trade. + if surplus == 0 then + if feed_last_w == 0 then return true end + local ok, werr = write_pv_surplus(0, "command") + if not ok then return "solar_pv: " .. tostring(werr) end + return true + end + + if not pv_reg.enable then + return "solar_pv: register 2107 (Solar PV master enable) not found on this pump" + end + if pv_reg.enable.value == nil then + return "solar_pv: register 2107 (Solar PV master enable) could not be read" + end + if tonumber(pv_reg.enable.value) ~= 1 then + return "solar_pv: the pump-side Solar PV input (register 2107) is disabled — " .. + "the owner must enable it on the pump first" + end + + if feed_last_w ~= nil then + local delta = surplus - feed_last_w + -- A decrease beyond the deadband is safety-direction (surplus + -- collapsed) and is written immediately; only small jitter and + -- rapid increases are swallowed. + if delta >= 0 or -delta < write_cfg.deadband_w then + if math.abs(delta) < write_cfg.deadband_w then return true end + if feed_write_ms and (feed_cmd_ms - feed_write_ms) < write_cfg.min_interval_ms then + return true + end + end + end + + local ok, werr = write_pv_surplus(surplus, "command") + if not ok then return "solar_pv: " .. tostring(werr) end + return true +end + +-- Per-poll feed maintenance: startup orphan clear + dead-man's switch. +local function maintain_pv_feed() + if not (pv_reg and pv_reg.avail) then return end + -- A previous run may have died with a non-zero feed standing. The + -- pump-side timeout for a silent feed is undocumented, so clear it + -- ourselves the first time we can. Enabling write.solar_pv declares FTW + -- the owner of this register, so a non-zero value we don't remember + -- writing is ours to clean up. The one-shot check is only consumed once + -- an actual number was read, and the per-size "not connected" sentinel + -- is NOT a standing feed — writing 0 over it would turn "no accessory" + -- into "accessory reporting zero", which changes pump behavior. + if not orphan_checked then + local raw = pv_reg.avail.raw + if type(raw) == "number" then + orphan_checked = true + local sentinel = size_sentinel(pv_reg.avail.size) + if feed_last_w == nil and raw > 0 and raw ~= sentinel then + local ok, err = write_pv_surplus(0, "clearing orphaned feed from a previous run") + if not ok then + orphan_checked = false -- retry next poll + host.log("warn", "NIBE: orphaned solar PV feed clear failed: " .. tostring(err)) + end + end + end + end + -- Dead-man's switch: commands stopped arriving → clear once. + if feed_last_w and feed_last_w > 0 and feed_cmd_ms and + (host.millis() - feed_cmd_ms) > write_cfg.ttl_ms then + local ok, err = write_pv_surplus(0, "feed stale — dead-man's switch") + if not ok then + host.log("warn", "NIBE: stale solar PV feed clear failed: " .. tostring(err)) + end + end +end + -- ---- Setup --------------------------------------------------------------- local function detect_serial() @@ -280,11 +539,11 @@ local function detect_serial() end -- Bring the driver to "ready" (serial known). Safe to call repeatedly; --- rate-limited by SETUP_RETRY_MS. Returns true once serial is established. +-- rate-limited by setup_retry_ms. Returns true once serial is established. local function try_setup() if serial then return true end local now = host.millis() - if last_setup_ms ~= nil and (now - last_setup_ms) < SETUP_RETRY_MS then + if last_setup_ms ~= nil and (now - last_setup_ms) < setup_retry_ms then return false end last_setup_ms = now @@ -295,7 +554,8 @@ local function try_setup() -- still win inside build_canon). local pkey, prof = select_profile(device_model, device_firmware) build_canon(prof, driver_config) - host.log("info", "NIBE: ready (read-only) serial=" .. serial .. " profile=" .. pkey) + local mode = write_cfg.solar_pv and "solar-pv feed armed" or "read-only" + host.log("info", "NIBE: ready (" .. mode .. ") serial=" .. serial .. " profile=" .. pkey) return true end @@ -320,13 +580,45 @@ function driver_init(config) auth_value = "Basic " .. base64_encode(username .. ":" .. password) if config.poll_interval_ms ~= nil then - POLL_INTERVAL_MS = tonumber(config.poll_interval_ms) or POLL_INTERVAL_MS + poll_interval_ms = tonumber(config.poll_interval_ms) or poll_interval_ms end if config.setup_retry_ms ~= nil then - SETUP_RETRY_MS = tonumber(config.setup_retry_ms) or SETUP_RETRY_MS + setup_retry_ms = tonumber(config.setup_retry_ms) or setup_retry_ms end if config.full_refresh_ms ~= nil then - FULL_REFRESH_MS = tonumber(config.full_refresh_ms) or FULL_REFRESH_MS + full_refresh_ms = tonumber(config.full_refresh_ms) or full_refresh_ms + end + + -- Solar PV feed config (write path). Off unless explicitly enabled, and + -- refused without a clamp ceiling: max_w bounds every value we could + -- ever write, so a missing max_w means the operator never stated the + -- site's PV nameplate and the risk clamp cannot do its job. + local w = config.write or {} + write_cfg = { + solar_pv = (w.solar_pv == true), + -- The operator asked for the feed at all: clear-only machinery + -- (orphan sweep, dead-man's switch, default-mode clear) stays armed + -- on this flag even when validation below refuses NEW writes, so a + -- config mistake never strands a feed a previous run left standing. + requested = (w.solar_pv == true), + max_w = tonumber(w.max_w) or 0, + deadband_w = tonumber(w.deadband_w) or 50, + ttl_ms = (tonumber(w.ttl_s) or 300) * 1000, + min_interval_ms = tonumber(w.min_interval_ms) or 60000, + enable_id = s(w.enable_id), + available_id = s(w.available_id), + disabled_reason = nil, + } + if write_cfg.solar_pv and write_cfg.max_w <= 0 then + write_cfg.solar_pv = false + write_cfg.disabled_reason = "write.max_w (site PV nameplate, W) is missing " .. + "or invalid — new writes are disabled" + host.log("error", "NIBE: " .. write_cfg.disabled_reason .. + " (feed clearing stays active)") + end + if write_cfg.solar_pv and not host.http_patch then + host.log("warn", "NIBE: this FTW core has no host.http_patch — " .. + "solar PV writes unavailable until the core is upgraded") end -- Build the headline lookup with the generic profile now; try_setup @@ -344,7 +636,7 @@ function driver_init(config) return end - host.set_poll_interval(POLL_INTERVAL_MS) + host.set_poll_interval(poll_interval_ms) -- Best-effort initial detection; driver_poll self-heals if it fails. if not try_setup() then host.log("warn", "NIBE: initial setup did not complete — will retry automatically") @@ -352,19 +644,24 @@ function driver_init(config) end function driver_poll() - if not base_url then return SETUP_RETRY_MS end + if not base_url then return setup_retry_ms end if not serial then - if not try_setup() then return SETUP_RETRY_MS end + if not try_setup() then return setup_retry_ms end end local data, err = api_get("/api/v1/devices/" .. serial .. "/points") if err then host.log("warn", "NIBE: points poll failed: " .. err) - return POLL_INTERVAL_MS + return poll_interval_ms + end + + if write_cfg.requested then + resolve_pv_points(data) + maintain_pv_feed() end local now = host.millis() - local full_refresh = last_full_emit_ms == nil or (now - last_full_emit_ms) >= FULL_REFRESH_MS + local full_refresh = last_full_emit_ms == nil or (now - last_full_emit_ms) >= full_refresh_ms -- Titles are not guaranteed unique. Count sanitized names first so every -- collision gets an id suffix deterministically; the old one-pass `seen` @@ -405,16 +702,34 @@ function driver_poll() -- ids are absent and whose non-headline values remain unchanged. host.emit_metric("hp_poll_ok", 1, "") - return POLL_INTERVAL_MS + return poll_interval_ms end -function driver_command(_action, _power_w, _cmd) - -- Read-only: no actuation. The pump stays in aidMode=off. +function driver_command(action, power_w, _cmd) + if action == "solar_pv" then + return solar_pv_command(power_w) + end + -- Anything else (battery dispatch, EV commands, …) stays rejected: this + -- driver's only actuator is the Solar PV feed. return false end function driver_default_mode() - -- Read-only: nothing to release. + -- Safe state = no feed standing. Fires on watchdog trip, on every tick + -- while the site meter is stale, and on driver stop — so it must be + -- idempotent, rate-limited and quiet when there is nothing to clear. + -- Gated on `requested`, not the validated flag: a bad max_w must not + -- disarm the clearing path. + if not write_cfg.requested then return end + if feed_last_w == nil or feed_last_w == 0 then return end + local now = host.millis() + if default_clear_ms and (now - default_clear_ms) < 60000 then return end + default_clear_ms = now + local ok, err = write_pv_surplus(0, "default mode") + if not ok then + host.log("warn", "NIBE: default-mode solar PV clear failed " .. + "(will retry): " .. tostring(err)) + end end function driver_cleanup() @@ -422,4 +737,10 @@ function driver_cleanup() last_setup_ms = nil last_full_emit_ms = nil last_emitted = {} + pv_reg = nil + feed_last_w = nil + feed_cmd_ms = nil + feed_write_ms = nil + orphan_checked = false + default_clear_ms = nil end diff --git a/drivers/tests/lua_harness/host_mock.lua b/drivers/tests/lua_harness/host_mock.lua index 28bd821..0abb7d4 100644 --- a/drivers/tests/lua_harness/host_mock.lua +++ b/drivers/tests/lua_harness/host_mock.lua @@ -44,6 +44,10 @@ host._modbus_registers = { -- { holding = {[addr]={reg1,...}}, input = {[addr]= input = {}, } host._http_responses = {} -- { [full_url] = response_body_string } +host._http_patches = {} -- ordered list of {url=..., body=..., headers=...} +host._http_patch_responses = {} -- { [url_or_substring] = response_body_string } +host._http_patch_error = nil -- set to an error string to fail PATCHes +host._poll_interval_ms = nil -- last value passed to set_poll_interval host._mqtt_buffer = {} -- list of {topic=..., payload=...} host._p1_data = nil -- P1 telegram table or nil host._modbus_write_error = nil @@ -82,6 +86,10 @@ function host.reset() host._fault_reason = "" host._modbus_registers = { holding = {}, input = {} } host._http_responses = {} + host._http_patches = {} + host._http_patch_responses = {} + host._http_patch_error = nil + host._poll_interval_ms = nil host._mqtt_buffer = {} host._p1_data = nil host._modbus_write_error = nil @@ -126,6 +134,13 @@ function host.set_device_fault(faulted, reason) host._fault_reason = reason or "" end +-- Both hosts provide this and spec/host-api-profile.json allows it; drivers +-- promoted from FTW call it during driver_init. +function host.set_poll_interval(interval_ms) + record_call("set_poll_interval", interval_ms) + host._poll_interval_ms = interval_ms +end + -- One diagnostic value outside the DER schema. Both hosts provide this and -- spec/host-api-profile.json allows it; the mock lacked it, so a driver using -- it failed here while working on hardware. @@ -329,6 +344,29 @@ function host.http_get(url) error("http_get: no mock response for URL: " .. tostring(url)) end +-- FTW's host.http_patch(url, body, headers) → (body, nil) or (nil, err). +-- Every PATCH is recorded in host._http_patches so tests can assert exactly +-- what a driver tried to write; responses come from +-- host._http_patch_responses (exact URL match, then substring match, then a +-- default "modified" body — the NIBE-style per-point success string). +function host.http_patch(url, body, headers) + record_call("http_patch", url, body, headers) + -- Recorded BEFORE the error injection so tests can always assert what a + -- driver attempted to write, even on the forced-failure path. + table.insert(host._http_patches, {url = url, body = body, headers = headers}) + if host._http_patch_error then + return nil, host._http_patch_error + end + local resp = host._http_patch_responses[url] + if resp then return resp end + for pattern_url, r in pairs(host._http_patch_responses) do + if string.find(url, pattern_url, 1, true) then + return r + end + end + return '[{"status":"modified"}]' +end + --------------------------------------------------------------------------- -- Serial functions --------------------------------------------------------------------------- diff --git a/drivers/tests/lua_harness/test_nibe_local_ftw.lua b/drivers/tests/lua_harness/test_nibe_local_ftw.lua new file mode 100644 index 0000000..c57c114 --- /dev/null +++ b/drivers/tests/lua_harness/test_nibe_local_ftw.lua @@ -0,0 +1,257 @@ +-- NIBE local-API driver: FTW v1 contract + the opt-in Solar PV write path +-- (srcfl/ftw#537). The fake pump speaks the Local REST API dialect FTW's +-- hermetic Go test established: points keyed by variableId, metadata carrying +-- modbusRegisterID/divisor/variableSize, values as datavalue objects. + +dofile("drivers/tests/lua_harness/host_mock.lua") + +local SERIAL = "06613225140002" +local BASE = "https://pump" +local DEVICES_URL = BASE .. "/api/v1/devices" +local POINTS_URL = BASE .. "/api/v1/devices/" .. SERIAL .. "/points" + +local DEVICES_BODY = + '{"devices":[{"product":{"manufacturer":"NIBE","name":"S735",' .. + '"firmwareId":"nibe-n","serialNumber":"' .. SERIAL .. '"}}]}' + +-- The Solar PV points live on DIFFERENT variableIds (5201/5202) than their +-- Modbus registers (2107/2109) — resolution must go through metadata. +local function points_body(enable_val, avail_raw) + return '{' .. + '"1801":{"title":"Compressor power","metadata":{"variableSize":"u32",' .. + '"unit":"W","divisor":1,"modbusRegisterID":1801,"isWritable":false},' .. + '"value":{"type":"datavalue","isOk":true,"integerValue":1234}},' .. + '"4":{"title":"Outdoor temp BT1","metadata":{"variableSize":"s16",' .. + '"unit":"\194\176C","divisor":10,"modbusRegisterID":4,"isWritable":false},' .. + '"value":{"type":"datavalue","isOk":true,"integerValue":-53}},' .. + '"5201":{"title":"Modbus TCP/IP Ext. (Solar PV)","metadata":{"variableSize":"u8",' .. + '"unit":"","divisor":1,"modbusRegisterID":2107,"isWritable":true},' .. + '"value":{"type":"datavalue","isOk":true,"integerValue":' .. tostring(enable_val) .. '}},' .. + '"5202":{"title":"Available power","metadata":{"variableSize":"u16",' .. + '"unit":"W","divisor":1,"modbusRegisterID":2109,"isWritable":true},' .. + '"value":{"type":"datavalue","isOk":true,"integerValue":' .. tostring(avail_raw) .. '}}' .. + '}' +end + +local function boot(write_config, enable_val, avail_raw) + host.reset() + dofile("drivers/lua/nibe_local.lua") + host._http_responses[DEVICES_URL] = DEVICES_BODY + host._http_responses[POINTS_URL] = points_body(enable_val, avail_raw) + driver_init({ + base_url = BASE, username = "u", password = "p", + write = write_config, + }) + local ok, err = pcall(driver_poll) + if not ok then error("driver_poll failed: " .. tostring(err)) end +end + +local function patch_count() + return #host._http_patches +end + +local function last_patch() + return host._http_patches[#host._http_patches] +end + +-- ---- Metadata and read-only-by-default contract --------------------------- + +boot(nil, 1, 0) + +if type(DRIVER) ~= "table" or DRIVER.id ~= "nibe-local" or DRIVER.version ~= "1.2.0" then + error("NIBE identity metadata is wrong") +end +if DRIVER.host_api_min ~= 1 or DRIVER.host_api_max ~= 1 then + error("NIBE host API range changed") +end +if type(driver_init) ~= "function" or type(driver_poll) ~= "function" or + type(driver_command) ~= "function" or type(driver_default_mode) ~= "function" or + type(driver_cleanup) ~= "function" then + error("NIBE lifecycle is incomplete") +end +if host._make ~= "NIBE" or host._sn ~= SERIAL then + error("NIBE did not report make/serial") +end +if not host._metrics.hp_power_w or host._metrics.hp_power_w.value ~= 1234 then + error("NIBE headline power metric missing or wrong") +end +if not host._metrics.hp_outdoor_temp_c or host._metrics.hp_outdoor_temp_c.value ~= -5.3 then + error("NIBE divisor scaling broke: " .. + tostring(host._metrics.hp_outdoor_temp_c and host._metrics.hp_outdoor_temp_c.value)) +end + +-- Without write config every write door is closed. +local res = driver_command("solar_pv", -3000, {}) +if type(res) ~= "string" or not string.find(res, "disabled", 1, true) then + error("solar_pv without opt-in must return an explanatory error, got: " .. tostring(res)) +end +if driver_command("battery", 1000, {}) ~= false then + error("non-solar_pv actions must still be rejected with false") +end +driver_default_mode() +driver_cleanup() +if patch_count() ~= 0 then + error("read-only lifecycle attempted an HTTP write") +end + +-- Enabling the feed without max_w must refuse NEW writes and say why. +boot({ solar_pv = true }, 1, 0) +res = driver_command("solar_pv", -3000, {}) +if type(res) ~= "string" or not string.find(res, "max_w", 1, true) then + error("solar_pv without max_w must name the missing key, got: " .. tostring(res)) +end +if patch_count() ~= 0 then error("missing max_w still allowed a write") end + +-- ...but the clearing machinery stays armed on the same invalid config: an +-- orphaned feed from a previous run is still swept on startup. +boot({ solar_pv = true }, 1, 620) +if patch_count() ~= 1 or not string.find(last_patch().body, '"integerValue":0', 1, true) then + error("invalid write config must not disarm the orphan sweep") +end + +-- ---- The write path ------------------------------------------------------- + +boot({ solar_pv = true, max_w = 9000, min_interval_ms = 0 }, 1, 0) +if patch_count() ~= 0 then error("poll with a clean pump must not write") end + +-- Site convention in, pump value out: -3000 W (export) → 3000. +res = driver_command("solar_pv", -3000, {}) +if res ~= true then error("solar_pv command failed: " .. tostring(res)) end +if patch_count() ~= 1 then error("expected exactly one PATCH") end +local p = last_patch() +if not string.find(p.url, "/api/v1/devices/" .. SERIAL .. "/points", 1, true) then + error("PATCH went to the wrong endpoint: " .. tostring(p.url)) +end +if not string.find(p.body, '"variableId":5202', 1, true) then + error("PATCH must target the resolved variableId of register 2109: " .. p.body) +end +if not string.find(p.body, '"integerValue":3000', 1, true) then + error("PATCH carried the wrong value: " .. p.body) +end +if not string.find(p.body, '"type":"datavalue"', 1, true) then + error("PATCH body is not a datavalue array: " .. p.body) +end +if not (p.headers and p.headers["Content-Type"] == "application/json") then + error("PATCH must declare a JSON content type") +end +if not host._metrics.hp_solar_pv_feed_w or host._metrics.hp_solar_pv_feed_w.value ~= 3000 then + error("feed observability metric missing") +end + +-- Clamp to max_w: a 50 kW claim must never reach the pump. +res = driver_command("solar_pv", -50000, {}) +if res ~= true then error("clamped command failed: " .. tostring(res)) end +if patch_count() ~= 2 or not string.find(last_patch().body, '"integerValue":9000', 1, true) then + error("clamp to write.max_w failed: " .. last_patch().body) +end + +-- Deadband: 20 W of movement is churn, not signal. +res = driver_command("solar_pv", -8980, {}) +if res ~= true or patch_count() ~= 2 then + error("deadband failed to swallow a 20 W change") +end + +-- Import (positive site W) means no surplus → explicit 0, always written. +res = driver_command("solar_pv", 2500, {}) +if res ~= true or patch_count() ~= 3 or + not string.find(last_patch().body, '"integerValue":0', 1, true) then + error("positive power_w must clear the feed to 0") +end +-- A second zero is a no-op, not another request. +res = driver_command("solar_pv", 0, {}) +if res ~= true or patch_count() ~= 3 then + error("repeated zero must not re-write") +end + +-- ---- Rate limiting keeps the safety direction ---------------------------- + +-- With the default 60 s min-interval: increases inside the window are +-- swallowed, but a collapse of the surplus is written immediately. +boot({ solar_pv = true, max_w = 9000 }, 1, 0) +res = driver_command("solar_pv", -9000, {}) +if res ~= true or patch_count() ~= 1 then error("first feed write failed") end +res = driver_command("solar_pv", -5000, {}) +if res ~= true or patch_count() ~= 2 or + not string.find(last_patch().body, '"integerValue":5000', 1, true) then + error("a large surplus decrease must bypass the rate limit") +end +res = driver_command("solar_pv", -8000, {}) +if res ~= true or patch_count() ~= 2 then + error("an increase inside min_interval must be swallowed") +end + +-- ---- Pump-side gates ------------------------------------------------------ + +-- Owner has not enabled the Solar PV input (2107 = 0): refuse loudly. +boot({ solar_pv = true, max_w = 9000 }, 0, 0) +res = driver_command("solar_pv", -3000, {}) +if type(res) ~= "string" or not string.find(res, "2107", 1, true) then + error("disabled 2107 must be surfaced, got: " .. tostring(res)) +end +if patch_count() ~= 0 then error("disabled 2107 still allowed a write") end + +-- An explicit clear bypasses the enable gate: the owner turning 2107 off +-- mid-feed must not block FTW from zeroing what it wrote. +res = driver_command("solar_pv", 0, {}) +if res ~= true or patch_count() ~= 1 or + not string.find(last_patch().body, '"integerValue":0', 1, true) then + error("clear must go through with 2107 disabled, got: " .. tostring(res)) +end + +-- Pump API left read-only (installer menu 7.5.15): HTTP 200 with a +-- per-point error string. Must be surfaced as failure, not success. +boot({ solar_pv = true, max_w = 9000 }, 1, 0) +host._http_patch_responses[POINTS_URL] = + '[{"status":"error: read only value"}]' +res = driver_command("solar_pv", -3000, {}) +if type(res) ~= "string" or not string.find(res, "7.5.15", 1, true) then + error("silent pump-side rejection must be surfaced, got: " .. tostring(res)) +end +if host._metrics.hp_solar_pv_feed_w then + error("a refused write must not report a live feed metric") +end + +-- ---- Dead-man's switch, orphan clear, default mode ------------------------ + +-- Commands stop arriving → the driver clears the feed on its own. +boot({ solar_pv = true, max_w = 9000, ttl_s = 60 }, 1, 0) +res = driver_command("solar_pv", -4000, {}) +if res ~= true or patch_count() ~= 1 then error("feed write failed") end +host._millis_counter = host._millis_counter + 120000 -- 2 min of silence +local ok2, err2 = pcall(driver_poll) +if not ok2 then error("poll during dead-man clear failed: " .. tostring(err2)) end +if patch_count() ~= 2 or not string.find(last_patch().body, '"integerValue":0', 1, true) then + error("dead-man's switch did not clear the stale feed") +end + +-- A previous run died mid-feed: the pump still shows 750 W. First poll +-- after startup must clear it (the pump-side timeout is undocumented). +boot({ solar_pv = true, max_w = 9000 }, 1, 750) +if patch_count() ~= 1 or not string.find(last_patch().body, '"integerValue":0', 1, true) then + error("orphaned feed was not cleared on startup") +end + +-- The u16 not-connected sentinel is NOT a standing feed: writing 0 over it +-- would turn "no accessory" into "accessory reporting zero". +boot({ solar_pv = true, max_w = 9000 }, 1, 65535) +if patch_count() ~= 0 then + error("orphan sweep must not clear the not-connected sentinel") +end + +-- default mode clears an active feed, once, and is quiet when idle. +boot({ solar_pv = true, max_w = 9000 }, 1, 0) +res = driver_command("solar_pv", -4000, {}) +if res ~= true then error("feed write failed: " .. tostring(res)) end +local before = patch_count() +driver_default_mode() +if patch_count() ~= before + 1 or + not string.find(last_patch().body, '"integerValue":0', 1, true) then + error("default mode did not clear the feed") +end +driver_default_mode() +driver_default_mode() +if patch_count() ~= before + 1 then + error("default mode must be idempotent once the feed is cleared") +end + +print("nibe_local FTW contract + solar PV write path: OK") diff --git a/drivers/tests/test_http_drivers.py b/drivers/tests/test_http_drivers.py index c365727..9ed065a 100644 --- a/drivers/tests/test_http_drivers.py +++ b/drivers/tests/test_http_drivers.py @@ -20,7 +20,12 @@ class TestHttpPatterns: """Validate HTTP driver API usage patterns.""" def test_constructs_base_url(self, driver_name): - """HTTP drivers should construct a base URL from config.host.""" + """HTTP drivers should construct a base URL from config.host. + + Both schemes are valid dialects: plain http for constrained LAN + devices, https for devices whose local API is TLS-only behind a + pinned certificate (NIBE's Local REST API). + """ code = read_driver(driver_name) clean = strip_lua_comments(code) @@ -28,9 +33,9 @@ def test_constructs_base_url(self, driver_name): # base_url = "http://" .. config.host .. ":" .. port # or similar patterns has_url_construction = ( - re.search(r'"http://".*config\.host', clean) - or re.search(r'config\.host.*"http://"', clean) - or re.search(r'base_url\s*=\s*"http://"', clean) + re.search(r'"https?://".*config\.host', clean) + or re.search(r'config\.host.*"https?://"', clean) + or re.search(r'base_url\s*=\s*"https?://"', clean) ) assert has_url_construction, ( @@ -68,8 +73,10 @@ def test_has_http_get_json_helper_or_inline(self, driver_name): code = read_driver(driver_name) clean = strip_lua_comments(code) - # Most HTTP drivers define a http_get_json helper or use pcall inline - has_helper = bool(re.search(r'function\s+http_get_json\s*\(', clean)) + # Most HTTP drivers define a http_get_json/api_get helper or use + # pcall inline; the helpers return nil-plus-error instead of raising. + has_helper = bool( + re.search(r'function\s+(?:http_get_json|api_get)\s*\(', clean)) has_inline_pcall = bool( re.search(r'pcall\s*\(\s*host\.http_get', clean) ) @@ -85,14 +92,19 @@ class TestHttpUrlSafety: """Validate URL construction safety.""" def test_uses_http_scheme(self, driver_name): - """HTTP drivers should use http:// scheme (not https on constrained devices).""" + """HTTP drivers must state an explicit scheme when building URLs. + + Plain http is the norm for constrained LAN devices; https is the + correct choice when the device's local API is TLS-only and the host + pins its certificate (NIBE's Local REST API). What is not acceptable + is a scheme-less URL left for the HTTP client to guess. + """ code = read_driver(driver_name) clean = strip_lua_comments(code) - # Remove comments for checking - # Should have http:// somewhere in URL construction - assert '"http://"' in clean, ( - f"{driver_name}: HTTP driver should use 'http://' scheme" + assert '"http://"' in clean or '"https://"' in clean, ( + f"{driver_name}: HTTP driver should construct URLs with an " + f"explicit 'http://' or 'https://' scheme" ) def test_uses_config_port(self, driver_name): diff --git a/drivers/tests/test_nibe_local.py b/drivers/tests/test_nibe_local.py new file mode 100644 index 0000000..c79a75b --- /dev/null +++ b/drivers/tests/test_nibe_local.py @@ -0,0 +1,32 @@ +"""NIBE local-API driver: FTW contract + the opt-in Solar PV write path. + +The driver is read-only by default. Its single write path feeds the pump's +native Solar PV surplus input (srcfl/ftw#537): value clamped to the operator's +stated PV nameplate, deadband against register churn, a dead-man's switch +because the pump-side timeout for a silent feed is undocumented, an orphan +clear on startup, and default mode as the universal off-switch. All of that +is exercised in one harness script against a fake pump that answers with the +Local REST API's real shapes -- including the API's most dangerous habit, +rejecting a write inside an HTTP 200. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +LUA = ROOT / "lua55" + +pytestmark = pytest.mark.skipif( + not LUA.exists(), reason="run make check to build ./lua55") + + +def test_nibe_local_contract_and_solar_pv_write_path(): + result = subprocess.run( + [str(LUA), "drivers/tests/lua_harness/test_nibe_local_ftw.lua"], + capture_output=True, text=True, cwd=ROOT) + assert result.returncode == 0, result.stdout + result.stderr + assert "OK" in result.stdout diff --git a/index.yaml b/index.yaml index acaa556..d57a42d 100644 --- a/index.yaml +++ b/index.yaml @@ -329,13 +329,13 @@ drivers: size_bytes: 15278 sha256: "d754fd4af96c2047f9d9e458a776ad8f10510569836618f4631dd70e2510f404" - name: "nibe_local" - version: "1.1.0" + version: "1.2.0" tier: core protocol: http ders: [meter] control: true - size_bytes: 18227 - sha256: "b89177004a2eff5a5b60e2f275f584f079acf02876c049b231ba3170bb35ac50" + size_bytes: 34651 + sha256: "24bced8a28261c51d919db8892b1917fd09ac2688de694864d1ed5bf9a2f1a13" - name: "opendtu" version: "1.0.2" tier: community diff --git a/manifests/nibe_local.yaml b/manifests/nibe_local.yaml index bfda04e..05a71be 100644 --- a/manifests/nibe_local.yaml +++ b/manifests/nibe_local.yaml @@ -1,5 +1,5 @@ name: "nibe_local" -version: "1.1.0" +version: "1.2.0" tier: core author: "Sourceful Labs AB" protocol: http @@ -11,12 +11,12 @@ tested_devices: variants: [NIBE S735] regions: [] firmware_versions: "" - notes: "Read-only NIBE S-series heat-pump telemetry over the on-prem Local REST API (HTTPS + Basic auth, self-signed cert pinned via tls_pin_sha256). Emits compressor/used power, lifetime energy meters, and the full ~980-point register map. Observe-only — no control." + notes: "NIBE S-series heat-pump telemetry over the on-prem Local REST API (HTTPS + Basic auth, self-signed cert pinned via tls_pin_sha256). Emits compressor/used power, lifetime energy meters, and the full ~980-point register map. Read-only by default; opt-in write path feeds the pump's native Solar PV surplus input (2107/2109)." min_driver_version: "1.0.0" min_host_version: "2.0.0" -size_bytes: 18227 +size_bytes: 34651 dkb_id: "nibe_local" -sha256: "b89177004a2eff5a5b60e2f275f584f079acf02876c049b231ba3170bb35ac50" +sha256: "24bced8a28261c51d919db8892b1917fd09ac2688de694864d1ed5bf9a2f1a13" signature: "" bytecode_sha256: "" diff --git a/spec/host-api-profile.json b/spec/host-api-profile.json index 6c83ed5..729f4bb 100644 --- a/spec/host-api-profile.json +++ b/spec/host-api-profile.json @@ -57,6 +57,7 @@ "http": [ "http_get", "http_post", + "http_patch", "https_get", "https_post", "json_decode" @@ -84,6 +85,7 @@ "transport": [ "http_get", "http_post", + "http_patch", "mqtt_pub", "mqtt_publish", "mqtt_sub", diff --git a/support-status.json b/support-status.json index 90368a2..e6f6131 100644 --- a/support-status.json +++ b/support-status.json @@ -1150,7 +1150,7 @@ }, { "catalog_source": true, - "catalog_version": "1.1.0", + "catalog_version": "1.2.0", "driver_id": "nibe_local", "package_id": null, "targets": {