diff --git a/CHANGELOG.md b/CHANGELOG.md index fcfeb47..341101a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ 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. The `DRIVER` block declares the path as `write_capabilities = { "solar_pv" }`, which is what lets a host offer a switch for it: FTW's Settings screen renders the feed's controls only for a driver that states it has a write path, so the alternative was an owner hand-editing two keys in `config.yaml` to use the feature at all - **A driver-authoring rule for the hybrid inverter that has no battery** — *A hybrid inverter may have no battery* in `docs/WRITING-A-DRIVER.md`, and rule 8 in `drivers/lua/GUIDELINES.md`. The existing rules cover a read that **failed**; this is the case where nothing failed — the device is healthy, every register answered, and the battery still is not there. Nearly every hybrid inverter is sold both with storage and without it under one model number and one register map, so a PV-only site is not an edge case, it is half the product line. The SG12RT already cited at the top of that document is this same fact arriving as an outage rather than as a wrong number. The rule: fill each battery field only from a register that answered, emit the DER only when at least one did, and **detect** it rather than reading it off the model number or asking the operator to declare it — the site nobody told the driver about is exactly the one that reports wrong. Records how the absence actually arrives, which is vendor-specific and cannot be assumed from one example: registers that go silent (Sigenergy), that answer a plain zero, that answer `0xFFFF`/`0x7FFFFFFF`/NaN (`sma` and `solis` already carry sentinel helpers), or that fault - The rule is stated with the catalog measured rather than asserted: **24 drivers emit both `pv` and `battery`, and 20 of them emit the battery DER with no guard on whether any battery register answered.** Two of the 20 (`ferroamp`, `zap`) gate it on configuration or on API discovery instead — better than nothing, and still not detection, which is why the rule names the difference. `sigenergy` 1.1.3 below is the worked example of the fix. The other 19 are not touched here: each is its own driver, its own register map and its own version, and a sweep that changes nineteen drivers at once is not reviewable - **`ders` deliberately stays as it is** in all of them. It describes what a driver can produce, not what one site has, and it reaches the signed artifact — so removing `battery` from a hybrid's manifest would cost a version and describe the driver less accurately than before diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 29e2003..aff2bc0 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -88,8 +88,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.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | | myuplink | 1.1.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| nibe_local | 1.1.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| nibe_local | 1.1.1 | 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 813d6df..6f5a70f 100644 --- a/devices.yaml +++ b/devices.yaml @@ -1011,11 +1011,11 @@ manufacturers: protocols: - protocol: http driver: "nibe_local" - version: "1.1.1" + version: "1.2.0" ders: [heatpump] - control: false + 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..4ed62f6 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,15 +108,16 @@ 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" }, + write_capabilities = { "solar_pv" }, connection_defaults = { port = 8443 }, } @@ -80,16 +132,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 +316,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 +540,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 +555,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 +581,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 +637,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 +645,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 +703,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 +738,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 b1fc7b1..9859ea1 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 @@ -83,6 +87,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 @@ -143,6 +151,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. @@ -346,6 +361,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 caa9a6b..4349b10 100644 --- a/index.yaml +++ b/index.yaml @@ -388,15 +388,15 @@ drivers: size_bytes: 15278 sha256: "d754fd4af96c2047f9d9e458a776ad8f10510569836618f4631dd70e2510f404" - name: "nibe_local" - version: "1.1.1" + version: "1.2.0" tier: core protocol: http connectivity: local setup: [device_screen] ders: [heatpump] - control: false - size_bytes: 18227 - sha256: "b89177004a2eff5a5b60e2f275f584f079acf02876c049b231ba3170bb35ac50" + control: true + size_bytes: 34690 + sha256: "69a74a8265dbb4c18270950d28bf94bd3342438f8a59ba936b4adb947c8ceca3" - name: "opendtu" version: "1.0.2" tier: community diff --git a/manifests/nibe_local.yaml b/manifests/nibe_local.yaml index 80c1c79..ba65441 100644 --- a/manifests/nibe_local.yaml +++ b/manifests/nibe_local.yaml @@ -1,19 +1,19 @@ name: "nibe_local" -version: "1.1.1" +version: "1.2.0" tier: core author: "HuggeK with the help of Claude Code" protocol: http connectivity: local setup: [device_screen] ders: [heatpump] -control: false +control: true tested_devices: - manufacturer: "NIBE" model_family: "NIBE REST API S-series" 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" upstream_docs: - url: "https://www.nibe.eu/webdav/files/myuplink_changelog/nibe-n.pdf" @@ -21,9 +21,9 @@ upstream_docs: kind: changelog url_stability: stable min_host_version: "2.0.0" -size_bytes: 18227 +size_bytes: 34690 dkb_id: "nibe_local" -sha256: "b89177004a2eff5a5b60e2f275f584f079acf02876c049b231ba3170bb35ac50" +sha256: "69a74a8265dbb4c18270950d28bf94bd3342438f8a59ba936b4adb947c8ceca3" 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 65096ba..4a26abd 100644 --- a/support-status.json +++ b/support-status.json @@ -1178,7 +1178,7 @@ }, { "catalog_source": true, - "catalog_version": "1.1.1", + "catalog_version": "1.2.0", "driver_id": "nibe_local", "package_id": null, "targets": {