From 253fa7d4a06a19d72814e577c70883af093063c8 Mon Sep 17 00:00:00 2001 From: hannesb90 Date: Tue, 10 Nov 2026 11:52:24 +0100 Subject: [PATCH 1/6] feat(wiser_heat): read-only Wiser heating hub over its local HTTPS API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One GET of /data/domain/ per minute: each room's temperature and setpoint, each smoke alarm's temperature, and each heating channel's demand and relay state. The hub's secret is a masked config value and its self-signed certificate is pinned with tls_pin_sha256. A room with nothing assigned reports -32768 and a -20.0 °C setpoint; neither is emitted. An offline device's cached temperature is left out. Co-Authored-By: Claude Opus 5.5 Signed-off-by: hannesb90 --- CHANGELOG.md | 13 ++ SUPPORT_STATUS.md | 2 + devices.yaml | 11 + drivers/lua/wiser_heat.lua | 207 ++++++++++++++++++ drivers/tests/lua_harness/test_wiser_heat.lua | 137 ++++++++++++ drivers/tests/test_wiser_heat.py | 24 ++ index.yaml | 10 + manifests/wiser_heat.yaml | 26 +++ support-status.json | 21 ++ 9 files changed, 451 insertions(+) create mode 100644 drivers/lua/wiser_heat.lua create mode 100644 drivers/tests/lua_harness/test_wiser_heat.lua create mode 100644 drivers/tests/test_wiser_heat.py create mode 100644 manifests/wiser_heat.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index aaf58aa..9eec7ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## [Unreleased] +### wiser_heat 0.1.0 + +New read-only driver for the Schneider Electric / Drayton Wiser heating hub +over its local HTTPS API. It reports each room's temperature and setpoint, +each smoke alarm's temperature, and each heating channel's demand and relay +state, from one GET of `/data/domain/` per minute. The hub's secret is a +masked config value, and its self-signed certificate is pinned with +`tls_pin_sha256`. + +A room with nothing assigned reports -32768 and a setpoint of -20.0 °C, and +neither is emitted. A device the hub shows as offline has its temperature +left out, since the hub would keep serving the last value. + ### easee_cloud 1.4.1 The driver says its session energy is coarse, with `session_wh_coarse`. Easee diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 1574553..1f0c10c 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -182,6 +182,8 @@ Catalog source is not proof that a target can install or run a driver. | victron_mqtt | 1.0.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | wallbox | 1.0.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | | wallbox | 1.0.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| wiser_heat | 0.1.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| wiser_heat | 0.1.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | zap | 3.1.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | | zap | 3.1.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | zaptec | 1.0.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | diff --git a/devices.yaml b/devices.yaml index 317220b..8da8ced 100644 --- a/devices.yaml +++ b/devices.yaml @@ -1238,6 +1238,17 @@ manufacturers: notes: "115200 baud, 8N1, DSMR v5.0.2 (SE) or EON payload (HU)" - name: "Schneider Electric" model_families: + - name: "Wiser Hub R gen 2" + variants: [WT704R1B30S4] + regions: [SE] + protocols: + - protocol: http + driver: "wiser_heat" + version: "0.1.0" + ders: [heatpump] + control: false + firmware_versions: "4.50.4" + notes: "Read-only over the hub's local HTTPS API (SECRET header, self-signed certificate pinned via tls_pin_sha256). Tested with one smoke alarm and no thermostat or valve; room temperatures from real thermostats are decoded but not yet seen on hardware." - name: "iEM3xxx/PM5xxx" variants: [PM5100, PM5110, PM5300, PM5310, PM5330, PM5340, PM710, iEM3150, iEM3155, iEM3250, iEM3255, iEM3350, iEM3355] regions: [] diff --git a/drivers/lua/wiser_heat.lua b/drivers/lua/wiser_heat.lua new file mode 100644 index 0000000..02210be --- /dev/null +++ b/drivers/lua/wiser_heat.lua @@ -0,0 +1,207 @@ +-- Schneider Electric / Drayton Wiser heating hub — local HTTPS API, read-only. +-- +-- Emits: metrics only, via host.emit_metric. No power stream, so no sign +-- conversion: the hub measures temperature and demand, not watts. +-- Control: none. New drivers start read-only. +-- +-- WHAT IT READS +-- One GET of https:///data/domain/ per poll, the hub's whole state as +-- JSON. From it: +-- - each Room: room__temp_c CalculatedTemperature, 0.1 °C +-- room__setpoint_c CurrentSetPoint, 0.1 °C +-- - each SmokeAlarmDevice: smoke_alarm__temp_c MeasuredTemperature +-- - each HeatingChannel: heating_channel__demand_pct PercentageDemand +-- heating_channel__relay_on HeatingRelayState +-- Ids are the hub's own and stay put while devices stay paired, so a room +-- keeps its metric name. Room names are owner text and stay out of metric +-- names. +-- +-- WHAT THE HUB SAYS WHEN IT KNOWS NOTHING +-- Measured on a Wiser Hub R gen 2 (WT704R1B30S4, firmware 4.50.4) on +-- 2026-11-10: +-- - A room with no thermostat or valve assigned reports +-- CalculatedTemperature -32768 and CurrentSetPoint -200, with +-- Invalid = "NothingAssigned". Neither is emitted. +-- - -200 (-20.0 °C) is also what Wiser uses for a room set to off, so a +-- setpoint at or below it is "no setpoint", not -20 °C. +-- The Wiser community libraries also treat 2000 (200.0 °C) as a sensor +-- error; it is dropped as well. +-- +-- STALENESS +-- The hub answers from its own cache. A device whose Device entry shows +-- DisplayedSignalStrength "Offline" or "NoSignal" has its temperature +-- left out, since the hub would otherwise keep serving its last value. +-- +-- SETUP +-- The hub's local API needs its SECRET, which the hub hands out on +-- GET http:///secret only while it is in setup mode (press Setup on +-- the hub; it leaves setup mode by itself after 15 minutes). The secret +-- is masked via config_secrets. The hub's certificate is self-signed, so +-- the host pins it with tls_pin_sha256: +-- openssl s_client -connect :443 " +-- secret: "" # masked via config_secrets +-- capabilities: +-- http: +-- allowed_hosts: [""] +-- tls_pin_sha256: "<64-hex-char certificate fingerprint>" + +DRIVER = { + host_api_min = 1, + host_api_max = 1, + id = "wiser_heat", + name = "Wiser heating hub", + manufacturer = "Schneider Electric", + version = "0.1.0", + protocols = { "http" }, + capabilities = { "apicreds" }, + read_only = true, + description = "Schneider Electric / Drayton Wiser heating hub via its local HTTPS API. Read-only: room temperatures and setpoints, smoke alarm temperatures, heating channel demand.", + homepage = "https://www.se.com/ww/en/product-range/65635-wiser-heating/", + authors = { "hannesb90", "Claude (Anthropic)" }, + tested_models = { "Wiser Hub R gen 2 (WT704R1B30S4)" }, + verification_status = "experimental", + verification_notes = "Checked against one Wiser Hub R gen 2 (WT704R1B30S4, firmware 4.50.4) with one smoke alarm and no thermostat or valve, on 2026-11-10. Room temperatures from real thermostats are decoded but not yet seen.", + config_secrets = { "secret" }, +} + +PROTOCOL = "http" + +local NO_TEMP = -32768 -- nothing measured +local TEMP_ERROR = 2000 -- 200.0 °C, a sensor error +local SETPOINT_OFF = -200 -- -20.0 °C, off or no control + +local POLL_MS = 60000 +local RETRY_MS = 30000 + +local url = nil +local headers = nil +local identity = false + +local function present(v) + return v ~= nil and v ~= "" and v ~= NO_TEMP +end + +-- A temperature in 0.1 °C, or nil. +local function temp(v) + v = tonumber(v) + if v == nil or v == NO_TEMP or v >= TEMP_ERROR then return nil end + return v / 10 +end + +local function emit(name, value, unit) + if value ~= nil then host.emit_metric(name, value, unit) end +end + +local OFFLINE = { Offline = true, NoSignal = true } + +local function device_online(devices, id) + local d = devices[id] + -- A device the hub does not list is not one it can vouch for. + if d == nil then return false end + return not OFFLINE[tostring(d.DisplayedSignalStrength)] +end + +local function read_identity(data) + if identity then return end + for _, d in ipairs(data.Device or {}) do + if d.ProductType == "Controller" then + if present(d.ModelIdentifier) then host.set_model(tostring(d.ModelIdentifier)) end + if present(d.UUID) then host.set_sn(tostring(d.UUID)) end + identity = true + local fw = d.ActiveFirmwareVersion + if present(fw) then host.log("info", "Wiser: hub firmware " .. tostring(fw)) end + return + end + end +end + +function driver_init(config) + host.set_make("Wiser") + config = config or {} + local function s(v) return (v ~= nil and v ~= "") and tostring(v) or nil end + -- base_url override exists for tests; production builds it from host. + local base = s(config.base_url) + if not base and s(config.host) then + -- The hub serves HTTPS on 443. A port is added only when the owner + -- configured another, so allowed_hosts can stay the bare address. + local port = s(config.port) + base = "https://" .. s(config.host) + if port and port ~= "443" then base = base .. ":" .. port end + end + if base then url = base .. "/data/domain/" end + local secret = s(config.secret) + if secret then headers = { SECRET = secret, Accept = "application/json" } end + if not url then host.log("warn", "Wiser: no host configured") end + if not headers then host.log("warn", "Wiser: no secret configured") end +end + +function driver_poll() + if not url or not headers then return RETRY_MS end + + local ok, body, err = pcall(host.http_get, url, headers) + if not ok then err = tostring(body); body = nil end + if body == nil then + -- Nothing from the hub means nothing is emitted: no reading here is + -- fresh. A 401 is a wrong or reset secret. + host.log("warn", "Wiser: hub did not answer: " .. tostring(err)) + host.emit_metric("wiser_poll_ok", 0, "") + return RETRY_MS + end + local dok, data = pcall(host.json_decode, body) + if not dok or type(data) ~= "table" then + host.log("warn", "Wiser: hub answer was not JSON") + host.emit_metric("wiser_poll_ok", 0, "") + return RETRY_MS + end + + read_identity(data) + + local devices = {} + for _, d in ipairs(data.Device or {}) do + if d.id ~= nil then devices[d.id] = d end + end + + for _, r in ipairs(data.Room or {}) do + local id = tonumber(r.id) + if id then + emit("room_" .. id .. "_temp_c", temp(r.CalculatedTemperature), "°C") + local sp = tonumber(r.CurrentSetPoint) + if sp ~= nil and sp > SETPOINT_OFF then + emit("room_" .. id .. "_setpoint_c", temp(sp), "°C") + end + end + end + + for _, s in ipairs(data.SmokeAlarmDevice or {}) do + local id = tonumber(s.id) + if id and device_online(devices, s.DeviceId) then + emit("smoke_alarm_" .. id .. "_temp_c", temp(s.MeasuredTemperature), "°C") + end + end + + for _, h in ipairs(data.HeatingChannel or {}) do + local id = tonumber(h.id) + if id then + emit("heating_channel_" .. id .. "_demand_pct", tonumber(h.PercentageDemand), "%") + if h.HeatingRelayState == "On" then + emit("heating_channel_" .. id .. "_relay_on", 1, "") + elseif h.HeatingRelayState == "Off" then + emit("heating_channel_" .. id .. "_relay_on", 0, "") + end + end + end + + host.emit_metric("wiser_poll_ok", 1, "") + return POLL_MS +end + +function driver_cleanup() +end diff --git a/drivers/tests/lua_harness/test_wiser_heat.lua b/drivers/tests/lua_harness/test_wiser_heat.lua new file mode 100644 index 0000000..b918ec8 --- /dev/null +++ b/drivers/tests/lua_harness/test_wiser_heat.lua @@ -0,0 +1,137 @@ +-- wiser_heat: decode the hub's /data/domain/ answer. +-- +-- HUB_R_GEN2 is the shape and values one Wiser Hub R gen 2 (WT704R1B30S4, +-- firmware 4.50.4) returned on 2026-11-10, with identifiers replaced and +-- unrelated sections removed: a room with nothing assigned, a smoke alarm, +-- and one heating channel. The later cases change single fields to cover +-- what that hub could not show: a room with a thermostat, an offline +-- device, a sensor error, a relay that is on. +-- +-- Usage: lua55 test_wiser_heat.lua + +local script_dir = arg[0]:match("(.*/)") or "./" +dofile(script_dir .. "host_mock.lua") + +local ROOT = script_dir .. "../../../" +local URL = "https://hub.test/data/domain/" +local failed = 0 + +local function fail(msg) + failed = failed + 1 + io.stderr:write("FAIL " .. msg .. "\n") +end + +local function expect(name, value, unit) + local m = host._metrics[name] + if not m then return fail(name .. ": not emitted") end + if type(m.value) ~= "number" or math.abs(m.value - value) > 0.001 then + fail(name .. ": value " .. tostring(m.value) .. " want " .. tostring(value)) + end + if unit and m.unit ~= unit then + fail(name .. ": unit " .. tostring(m.unit) .. " want " .. unit) + end +end + +local function absent(name) + if host._metrics[name] ~= nil then + fail(name .. ": emitted " .. tostring(host._metrics[name].value) .. ", want absent") + end +end + +local HUB_R_GEN2 = [[{ + "Device": [ + {"id": 0, "ProductType": "Controller", "ModelIdentifier": "WT704R1B30S4", + "ActiveFirmwareVersion": "4.50.4", "DisplayedSignalStrength": "VeryGood", + "UUID": "HUB-UUID"}, + {"id": 2, "ProductType": "SmokeAlarmDevice", "ModelIdentifier": "Unknown", + "ActiveFirmwareVersion": "2.7.6", "DisplayedSignalStrength": "Good"} + ], + "Room": [ + {"id": 1, "Name": "Room", "Mode": "Manual", + "CalculatedTemperature": -32768, "CurrentSetPoint": -200, + "DisplayedSetPoint": -200, "Invalid": "NothingAssigned", + "SetpointOrigin": "FromNoControl"} + ], + "SmokeAlarmDevice": [ + {"id": 1, "DeviceId": 2, "MeasuredTemperature": 210, "ACMains": false, + "SmokeAlarm": false, "HeatAlarm": false} + ], + "HeatingChannel": [ + {"id": 1, "Name": "Channel-1", "PercentageDemand": 0, + "DemandOnOffOutput": "Off", "HeatingRelayState": "Off"} + ] +}]] + +local function run(body, config) + host.reset() + host._http_responses[URL] = body + dofile(ROOT .. "drivers/lua/wiser_heat.lua") + driver_init(config or { base_url = "https://hub.test", secret = "s" }) + return driver_poll() +end + +-- The hub as it answered. +local interval = run(HUB_R_GEN2) +if interval ~= 60000 then fail("poll interval " .. tostring(interval)) end +if host._make ~= "Wiser" then fail("make " .. tostring(host._make)) end +if host._model ~= "WT704R1B30S4" then fail("model " .. tostring(host._model)) end +if host._sn ~= "HUB-UUID" then fail("sn " .. tostring(host._sn)) end +expect("smoke_alarm_1_temp_c", 21.0, "°C") +expect("heating_channel_1_demand_pct", 0, "%") +expect("heating_channel_1_relay_on", 0) +expect("wiser_poll_ok", 1) +-- Nothing assigned to the room: no temperature and no -20 °C setpoint. +absent("room_1_temp_c") +absent("room_1_setpoint_c") + +-- A room with a thermostat, a relay that is on. +local thermostat = HUB_R_GEN2 + :gsub('"CalculatedTemperature": %-32768', '"CalculatedTemperature": 196') + :gsub('"CurrentSetPoint": %-200', '"CurrentSetPoint": 205') + :gsub('"PercentageDemand": 0', '"PercentageDemand": 45') + :gsub('"HeatingRelayState": "Off"', '"HeatingRelayState": "On"') +run(thermostat) +expect("room_1_temp_c", 19.6, "°C") +expect("room_1_setpoint_c", 20.5, "°C") +expect("heating_channel_1_demand_pct", 45, "%") +expect("heating_channel_1_relay_on", 1) + +-- A sensor error is not a 200 °C room. +run((HUB_R_GEN2:gsub('"CalculatedTemperature": %-32768', '"CalculatedTemperature": 2000'))) +absent("room_1_temp_c") + +-- An offline smoke alarm: the hub's cached value is not fresh. +run((HUB_R_GEN2:gsub('"DisplayedSignalStrength": "Good"', '"DisplayedSignalStrength": "Offline"'))) +absent("smoke_alarm_1_temp_c") +expect("heating_channel_1_demand_pct", 0, "%") + +-- The hub does not answer (wrong secret, or offline): nothing is emitted. +host.reset() +dofile(ROOT .. "drivers/lua/wiser_heat.lua") +driver_init({ base_url = "https://hub.test", secret = "s" }) +local retry = driver_poll() +if retry ~= 30000 then fail("retry interval " .. tostring(retry)) end +absent("smoke_alarm_1_temp_c") +absent("heating_channel_1_demand_pct") +expect("wiser_poll_ok", 0) + +-- Not JSON. +run("Bad request") +absent("smoke_alarm_1_temp_c") +expect("wiser_poll_ok", 0) + +-- No secret: the driver does not call the hub at all. +host.reset() +dofile(ROOT .. "drivers/lua/wiser_heat.lua") +driver_init({ base_url = "https://hub.test" }) +driver_poll() +for _, c in ipairs(host._calls or {}) do + if c.func == "http_get" then fail("called the hub without a secret") end +end +absent("wiser_poll_ok") + +if failed > 0 then + print("FAILED " .. failed) + os.exit(1) +end +print("PASS") diff --git a/drivers/tests/test_wiser_heat.py b/drivers/tests/test_wiser_heat.py new file mode 100644 index 0000000..87495e8 --- /dev/null +++ b/drivers/tests/test_wiser_heat.py @@ -0,0 +1,24 @@ +"""wiser_heat decodes what a Wiser Hub R gen 2 actually returned.""" + +from pathlib import Path +import subprocess + +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_wiser_heat_decodes_hub_domain(): + result = subprocess.run( + [str(LUA), "drivers/tests/lua_harness/test_wiser_heat.lua"], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "PASS" in result.stdout diff --git a/index.yaml b/index.yaml index 0f09d7f..662878b 100644 --- a/index.yaml +++ b/index.yaml @@ -822,6 +822,16 @@ drivers: control: false size_bytes: 2842 sha256: "1c0362e0a25f1fe297de838a85089eb7676b25e78a7d6d1d03ccea1258b94403" + - name: "wiser_heat" + version: "0.1.0" + tier: community + protocol: http + connectivity: local + setup: [device_ui] + ders: [heatpump] + control: false + size_bytes: 8072 + sha256: "5ed78200fe13ac005bd0771dd60d3c407e4a5aed2a5527b7b748b552941ae06d" - name: "zap" version: "3.1.3" tier: core diff --git a/manifests/wiser_heat.yaml b/manifests/wiser_heat.yaml new file mode 100644 index 0000000..9db3e24 --- /dev/null +++ b/manifests/wiser_heat.yaml @@ -0,0 +1,26 @@ +name: "wiser_heat" +version: "0.1.0" +tier: community +author: "hannesb90 with the help of Claude Code" +protocol: http +connectivity: local +setup: [device_ui] +ders: [heatpump] +control: false +tested_devices: + - manufacturer: "Schneider Electric" + model_family: "Wiser Hub R gen 2" + variants: [WT704R1B30S4] + regions: [SE] + firmware_versions: "4.50.4" + notes: "Read-only over the hub's local HTTPS API (SECRET header, self-signed certificate pinned via tls_pin_sha256). Tested with one smoke alarm and no thermostat or valve; room temperatures from real thermostats are decoded but not yet seen on hardware." + min_driver_version: "0.1.0" +min_host_version: "2.0.0" +size_bytes: 8072 +dkb_id: "wiser_heat" +sha256: "5ed78200fe13ac005bd0771dd60d3c407e4a5aed2a5527b7b748b552941ae06d" +signature: "" +bytecode_sha256: "" +bytecode_signature: "" +bytecode_size: 0 +changelog: "First version: read-only Wiser hub rooms, smoke alarm temperatures and heating channel demand." diff --git a/support-status.json b/support-status.json index 65543b7..4c064d9 100644 --- a/support-status.json +++ b/support-status.json @@ -1869,6 +1869,27 @@ } } }, + { + "catalog_source": true, + "catalog_version": "0.1.0", + "driver_id": "wiser_heat", + "targets": { + "blixt-l1": { + "hil": "not_recorded", + "historical_signed_beta_version": null, + "legacy_parity": "not_assessed", + "note": "", + "target_conformance": "not_assessed" + }, + "ftw-core": { + "hil": "not_recorded", + "historical_signed_beta_version": null, + "legacy_parity": "not_assessed", + "note": "", + "target_conformance": "not_assessed" + } + } + }, { "catalog_source": true, "catalog_version": "3.1.3", From fdd91ef83c9ed5d9b6465d8f4b6178c9337d1cf9 Mon Sep 17 00:00:00 2001 From: hannesb90 Date: Tue, 10 Nov 2026 17:03:01 +0100 Subject: [PATCH 2/6] fix(wiser_heat): poll once a minute via set_poll_interval The host caps an interval returned from driver_poll at 30 s, so returning 60000 alone polled the hub every 30 s, twice as often as meant. Each poll now sets its next interval with host.set_poll_interval (60 s, or 30 s after a failure) and returns the same value. The test checks the interval the host is told, not only the one returned. Co-Authored-By: Claude Opus 5.5 Signed-off-by: hannesb90 --- drivers/lua/wiser_heat.lua | 16 ++++++++++++---- drivers/tests/lua_harness/test_wiser_heat.lua | 12 ++++++++++++ index.yaml | 4 ++-- manifests/wiser_heat.yaml | 4 ++-- 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/drivers/lua/wiser_heat.lua b/drivers/lua/wiser_heat.lua index 02210be..91fa3c8 100644 --- a/drivers/lua/wiser_heat.lua +++ b/drivers/lua/wiser_heat.lua @@ -78,9 +78,17 @@ local NO_TEMP = -32768 -- nothing measured local TEMP_ERROR = 2000 -- 200.0 °C, a sensor error local SETPOINT_OFF = -200 -- -20.0 °C, off or no control +-- Applied with host.set_poll_interval: an interval returned from driver_poll +-- is capped at 30 s by the host, so returning 60000 alone would poll the +-- hub twice as often as meant. Returned too, so the two always agree. local POLL_MS = 60000 local RETRY_MS = 30000 +local function next_poll(ms) + host.set_poll_interval(ms) + return ms +end + local url = nil local headers = nil local identity = false @@ -144,7 +152,7 @@ function driver_init(config) end function driver_poll() - if not url or not headers then return RETRY_MS end + if not url or not headers then return next_poll(RETRY_MS) end local ok, body, err = pcall(host.http_get, url, headers) if not ok then err = tostring(body); body = nil end @@ -153,13 +161,13 @@ function driver_poll() -- fresh. A 401 is a wrong or reset secret. host.log("warn", "Wiser: hub did not answer: " .. tostring(err)) host.emit_metric("wiser_poll_ok", 0, "") - return RETRY_MS + return next_poll(RETRY_MS) end local dok, data = pcall(host.json_decode, body) if not dok or type(data) ~= "table" then host.log("warn", "Wiser: hub answer was not JSON") host.emit_metric("wiser_poll_ok", 0, "") - return RETRY_MS + return next_poll(RETRY_MS) end read_identity(data) @@ -200,7 +208,7 @@ function driver_poll() end host.emit_metric("wiser_poll_ok", 1, "") - return POLL_MS + return next_poll(POLL_MS) end function driver_cleanup() diff --git a/drivers/tests/lua_harness/test_wiser_heat.lua b/drivers/tests/lua_harness/test_wiser_heat.lua index b918ec8..c1fae47 100644 --- a/drivers/tests/lua_harness/test_wiser_heat.lua +++ b/drivers/tests/lua_harness/test_wiser_heat.lua @@ -71,8 +71,19 @@ local function run(body, config) end -- The hub as it answered. + +-- The interval the host will actually use: the last set_poll_interval. +local function set_interval() + local ms + for _, call in ipairs(host._calls) do + if call.func == "set_poll_interval" then ms = call.args[1] end + end + return ms +end local interval = run(HUB_R_GEN2) if interval ~= 60000 then fail("poll interval " .. tostring(interval)) end +-- The host caps a returned interval at 30 s; the minute has to be set. +if set_interval() ~= 60000 then fail("set_poll_interval " .. tostring(set_interval())) end if host._make ~= "Wiser" then fail("make " .. tostring(host._make)) end if host._model ~= "WT704R1B30S4" then fail("model " .. tostring(host._model)) end if host._sn ~= "HUB-UUID" then fail("sn " .. tostring(host._sn)) end @@ -111,6 +122,7 @@ dofile(ROOT .. "drivers/lua/wiser_heat.lua") driver_init({ base_url = "https://hub.test", secret = "s" }) local retry = driver_poll() if retry ~= 30000 then fail("retry interval " .. tostring(retry)) end +if set_interval() ~= 30000 then fail("set retry interval " .. tostring(set_interval())) end absent("smoke_alarm_1_temp_c") absent("heating_channel_1_demand_pct") expect("wiser_poll_ok", 0) diff --git a/index.yaml b/index.yaml index 662878b..7c95903 100644 --- a/index.yaml +++ b/index.yaml @@ -830,8 +830,8 @@ drivers: setup: [device_ui] ders: [heatpump] control: false - size_bytes: 8072 - sha256: "5ed78200fe13ac005bd0771dd60d3c407e4a5aed2a5527b7b748b552941ae06d" + size_bytes: 8418 + sha256: "ba2295c6b284c7a90254a83ebf8772427f64edfdb2bb245271cb34ca0909606c" - name: "zap" version: "3.1.3" tier: core diff --git a/manifests/wiser_heat.yaml b/manifests/wiser_heat.yaml index 9db3e24..dd56ce8 100644 --- a/manifests/wiser_heat.yaml +++ b/manifests/wiser_heat.yaml @@ -16,9 +16,9 @@ tested_devices: notes: "Read-only over the hub's local HTTPS API (SECRET header, self-signed certificate pinned via tls_pin_sha256). Tested with one smoke alarm and no thermostat or valve; room temperatures from real thermostats are decoded but not yet seen on hardware." min_driver_version: "0.1.0" min_host_version: "2.0.0" -size_bytes: 8072 +size_bytes: 8418 dkb_id: "wiser_heat" -sha256: "5ed78200fe13ac005bd0771dd60d3c407e4a5aed2a5527b7b748b552941ae06d" +sha256: "ba2295c6b284c7a90254a83ebf8772427f64edfdb2bb245271cb34ca0909606c" signature: "" bytecode_sha256: "" bytecode_signature: "" From 4b26d57e8572584bb0a466fa1453aff1fac0a64f Mon Sep 17 00:00:00 2001 From: hannesb90 Date: Wed, 11 Nov 2026 18:26:26 +0100 Subject: [PATCH 3/6] feat(wiser_heat): read floor thermostats and room demand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Renova CFMT 16 A floor thermostat is now paired with the test hub, so room temperatures from a real thermostat are seen on hardware. Each room also reports its demand, its output state and whether its temperature is floor or air (room__temp_is_floor): in a floor-control preset the room temperature is the floor sensor's. Each heating actuator with a floor sensor reports the floor temperature, while the sensor is in normal operation, and its upper and lower limits. An offline thermostat's cached values are left out. The hub reports the configured floor sensor type but cannot see the probe. On the test site a 15 kOhm probe configured as 10 kOhm read 17.4 °C against 26.6-27.5 °C measured on the floor; the type is logged when it changes. Co-Authored-By: Claude Opus 5.5 Signed-off-by: hannesb90 --- CHANGELOG.md | 13 ++- devices.yaml | 13 ++- drivers/lua/wiser_heat.lua | 63 +++++++++++- drivers/tests/lua_harness/test_wiser_heat.lua | 98 +++++++++++++++++++ index.yaml | 4 +- manifests/wiser_heat.yaml | 15 ++- 6 files changed, 193 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eec7ab..a264896 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,10 @@ ### wiser_heat 0.1.0 New read-only driver for the Schneider Electric / Drayton Wiser heating hub -over its local HTTPS API. It reports each room's temperature and setpoint, -each smoke alarm's temperature, and each heating channel's demand and relay -state, from one GET of `/data/domain/` per minute. The hub's secret is a +over its local HTTPS API. It reports each room's temperature, setpoint, +demand and output state, each floor thermostat's floor sensor temperature +and floor limits, each smoke alarm's temperature, and each heating channel's +demand and relay state, from one GET of `/data/domain/` per minute. The hub's secret is a masked config value, and its self-signed certificate is pinned with `tls_pin_sha256`. @@ -15,6 +16,12 @@ A room with nothing assigned reports -32768 and a setpoint of -20.0 °C, and neither is emitted. A device the hub shows as offline has its temperature left out, since the hub would keep serving the last value. +A floor thermostat in a floor-control preset makes the room's temperature the +floor's, so `room__temp_is_floor` says whether a room temperature is floor +or air. Checked on a Renova CFMT 16 A in presets P8 and P6. The hub reports +the configured floor sensor type but cannot see the probe: a 15 kOhm probe +set as 10 kOhm read about 9 °C low. The driver logs the type when it changes. + ### easee_cloud 1.4.1 The driver says its session energy is coarse, with `session_wh_coarse`. Easee diff --git a/devices.yaml b/devices.yaml index 8da8ced..b834b64 100644 --- a/devices.yaml +++ b/devices.yaml @@ -1238,6 +1238,17 @@ manufacturers: notes: "115200 baud, 8N1, DSMR v5.0.2 (SE) or EON payload (HU)" - name: "Schneider Electric" model_families: + - name: "Renova Connected Thermostat 16 A" + variants: [WDE011680] + regions: [SE] + protocols: + - protocol: http + driver: "wiser_heat" + version: "0.1.0" + ders: [heatpump] + control: false + firmware_versions: "1.0.59" + notes: "Read through the Wiser hub, on electric floor heating with a 15 kOhm floor sensor, in presets P8 (floor control) and P6 (room control with floor limit). Room air temperature matched the thermostat display, and the floor sensor (after setting NTC_15K) matched an IR reading of the floor surface; room demand and output state followed the relay through a heat and cool-down cycle. The floor lower limit is decoded but was not configured." - name: "Wiser Hub R gen 2" variants: [WT704R1B30S4] regions: [SE] @@ -1248,7 +1259,7 @@ manufacturers: ders: [heatpump] control: false firmware_versions: "4.50.4" - notes: "Read-only over the hub's local HTTPS API (SECRET header, self-signed certificate pinned via tls_pin_sha256). Tested with one smoke alarm and no thermostat or valve; room temperatures from real thermostats are decoded but not yet seen on hardware." + notes: "Read-only over the hub's local HTTPS API (SECRET header, self-signed certificate pinned via tls_pin_sha256). Tested with smoke alarms and one Renova CFMT 16 A floor thermostat; radiator valves (iTRV) and room stats are not seen on hardware." - name: "iEM3xxx/PM5xxx" variants: [PM5100, PM5110, PM5300, PM5310, PM5330, PM5340, PM710, iEM3150, iEM3155, iEM3250, iEM3255, iEM3350, iEM3355] regions: [] diff --git a/drivers/lua/wiser_heat.lua b/drivers/lua/wiser_heat.lua index 91fa3c8..20c0bde 100644 --- a/drivers/lua/wiser_heat.lua +++ b/drivers/lua/wiser_heat.lua @@ -9,6 +9,15 @@ -- JSON. From it: -- - each Room: room__temp_c CalculatedTemperature, 0.1 °C -- room__setpoint_c CurrentSetPoint, 0.1 °C +-- room__temp_is_floor 1 when the room is controlled on +-- floor temperature, 0 on air +-- room__demand_pct PercentageDemand +-- room__output_on ControlOutputState +-- - each HeatingActuator with a floor sensor (e.g. a Renova CFMT 16 A +-- floor thermostat): +-- heating_actuator__floor_temp_c floor sensor, 0.1 °C +-- heating_actuator__floor_max_c floor upper limit +-- heating_actuator__floor_min_c floor lower limit -- - each SmokeAlarmDevice: smoke_alarm__temp_c MeasuredTemperature -- - each HeatingChannel: heating_channel__demand_pct PercentageDemand -- heating_channel__relay_on HeatingRelayState @@ -27,6 +36,23 @@ -- The Wiser community libraries also treat 2000 (200.0 °C) as a sensor -- error; it is dropped as well. -- +-- ROOM TEMPERATURE CAN BE A FLOOR TEMPERATURE +-- A floor thermostat in a floor-control preset (Renova CFMT P7/P8) makes +-- the room's CalculatedTemperature the floor sensor's reading +-- (InputTemperatureType "Floor"); in a room preset with floor limit (P6) +-- it is the thermostat's air sensor ("Air") and the floor sensor only +-- bounds it. room__temp_is_floor says which, so 26 °C of floor is not +-- read as 26 °C of air. +-- +-- FLOOR SENSOR TYPE +-- The hub reports the configured sensor type (NTC_10K, NTC_15K, ...) but +-- cannot know the probe in the floor. A wrong type still reads plausibly: +-- on the test site a 15 kOhm probe configured as 10 kOhm read 17.4 °C +-- while the floor surface measured 26.6-27.5 °C, and after setting +-- NTC_15K it read 27.0 °C. The type is logged when it changes, and the +-- floor reading is emitted only while the sensor status is +-- Normal_Operation. +-- -- STALENESS -- The hub answers from its own cache. A device whose Device entry shows -- DisplayedSignalStrength "Offline" or "NoSignal" has its temperature @@ -63,12 +89,12 @@ DRIVER = { protocols = { "http" }, capabilities = { "apicreds" }, read_only = true, - description = "Schneider Electric / Drayton Wiser heating hub via its local HTTPS API. Read-only: room temperatures and setpoints, smoke alarm temperatures, heating channel demand.", + description = "Schneider Electric / Drayton Wiser heating hub via its local HTTPS API. Read-only: room temperatures, setpoints and demand, floor thermostat floor temperature and limits, smoke alarm temperatures, heating channel demand.", homepage = "https://www.se.com/ww/en/product-range/65635-wiser-heating/", authors = { "hannesb90", "Claude (Anthropic)" }, - tested_models = { "Wiser Hub R gen 2 (WT704R1B30S4)" }, + tested_models = { "Wiser Hub R gen 2 (WT704R1B30S4)", "Renova Connected Thermostat 16 A (WDE011680)" }, verification_status = "experimental", - verification_notes = "Checked against one Wiser Hub R gen 2 (WT704R1B30S4, firmware 4.50.4) with one smoke alarm and no thermostat or valve, on 2026-11-10. Room temperatures from real thermostats are decoded but not yet seen.", + verification_notes = "Checked against one Wiser Hub R gen 2 (WT704R1B30S4, firmware 4.50.4) with smoke alarms and one Renova CFMT 16 A floor thermostat (WDE011680) on electric floor heating, on 2026-11-10 and 2026-11-11, in both the floor-control (P8) and room-with-floor-limit (P6) presets. Radiator valves (iTRV) and room stats are decoded only as rooms; not seen.", config_secrets = { "secret" }, } @@ -109,6 +135,12 @@ local function emit(name, value, unit) end local OFFLINE = { Offline = true, NoSignal = true } +local ON_OFF = { On = 1, Off = 0 } +local IS_FLOOR = { Floor = 1, Air = 0 } + +-- Last floor sensor type and status logged per actuator, so a change is +-- logged once rather than every minute. +local floor_seen = {} local function device_online(devices, id) local d = devices[id] @@ -185,6 +217,31 @@ function driver_poll() if sp ~= nil and sp > SETPOINT_OFF then emit("room_" .. id .. "_setpoint_c", temp(sp), "°C") end + -- A room with nothing assigned has none of these fields. + if temp(r.CalculatedTemperature) ~= nil then + emit("room_" .. id .. "_temp_is_floor", IS_FLOOR[r.InputTemperatureType], "") + end + emit("room_" .. id .. "_demand_pct", tonumber(r.PercentageDemand), "%") + emit("room_" .. id .. "_output_on", ON_OFF[r.ControlOutputState], "") + end + end + + for _, a in ipairs(data.HeatingActuator or {}) do + local id = tonumber(a.id) + local f = a.FloorTemperatureSensor + if id and type(f) == "table" and device_online(devices, a.DeviceId) then + local seen = tostring(f.SensorType) .. "/" .. tostring(f.Status) + if floor_seen[id] ~= seen then + floor_seen[id] = seen + host.log("info", "Wiser: heating actuator " .. id .. " floor sensor " + .. tostring(f.SensorType) .. ", status " .. tostring(f.Status)) + end + local p = "heating_actuator_" .. id + if f.Status == "Normal_Operation" then + emit(p .. "_floor_temp_c", temp(f.MeasuredTemperature), "°C") + end + emit(p .. "_floor_max_c", temp(f.MaximumTemperature), "°C") + emit(p .. "_floor_min_c", temp(f.MinimumTemperature), "°C") end end diff --git a/drivers/tests/lua_harness/test_wiser_heat.lua b/drivers/tests/lua_harness/test_wiser_heat.lua index c1fae47..780e229 100644 --- a/drivers/tests/lua_harness/test_wiser_heat.lua +++ b/drivers/tests/lua_harness/test_wiser_heat.lua @@ -116,6 +116,104 @@ run((HUB_R_GEN2:gsub('"DisplayedSignalStrength": "Good"', '"DisplayedSignalStren absent("smoke_alarm_1_temp_c") expect("heating_channel_1_demand_pct", 0, "%") +-- No floor thermostat fields for a room with nothing assigned. +run(HUB_R_GEN2) +absent("room_1_temp_is_floor") +absent("room_1_demand_pct") +absent("room_1_output_on") + +-- HUB_CFMT is what the same hub returned on 2026-11-11 with a Renova CFMT +-- 16 A floor thermostat (WDE011680) on electric floor heating, in preset P6 +-- (room control on the air sensor, floor sensor as limit), identifiers +-- replaced and unrelated fields removed. +local HUB_CFMT = [[{ + "Device": [ + {"id": 0, "ProductType": "Controller", "ModelIdentifier": "WT704R1B30S4", + "ActiveFirmwareVersion": "4.50.4", "DisplayedSignalStrength": "VeryGood", + "UUID": "HUB-UUID"}, + {"id": 8, "ProductType": "CFMT", "ProductIdentifier": "RenovaCFMT16A", + "ProductModel": "WDE011680", "ModelIdentifier": "Unknown", "RoomId": 6, + "DisplayedSignalStrength": "VeryGood"} + ], + "Room": [ + {"id": 6, "Name": "Room", "Mode": "Auto", "DemandType": "Modulating", + "CalculatedTemperature": 242, "CurrentSetPoint": 200, + "FloorSensorState": "InRange", "PercentageDemand": 0, + "ControlOutputState": "Off", "SetpointOrigin": "FromManualOverride", + "InputTemperatureType": "Air"} + ], + "HeatingActuator": [ + {"id": 8, "DeviceId": 8, "ThermostatApplication": "OccupiedSpace", + "HeatingEmitter": "Floor", "ControlLoop": "PI", + "LocalTemperatureSourceSelect": "Ambient", "OutputType": "Relay", + "OutputRelayEnabled": true, + "FloorTemperatureSensor": {"MaximumTemperature": 300, "Offset": 0, + "SensorType": "NTC_15K", "MeasuredTemperature": 263, + "Status": "Normal_Operation"}, + "MeasuredTemperature": 242, "OccupiedHeatingSetPoint": 200, + "InstantaneousDemand": 0} + ], + "HeatingChannel": [ + {"id": 1, "Name": "Channel-1", "PercentageDemand": 0, + "DemandOnOffOutput": "Off", "HeatingRelayState": "Off"} + ] +}]] + +-- P6: the room is air, the floor sensor is reported on its own. +run(HUB_CFMT) +expect("room_6_temp_c", 24.2, "°C") +expect("room_6_setpoint_c", 20.0, "°C") +expect("room_6_temp_is_floor", 0) +expect("room_6_demand_pct", 0, "%") +expect("room_6_output_on", 0) +expect("heating_actuator_8_floor_temp_c", 26.3, "°C") +expect("heating_actuator_8_floor_max_c", 30.0, "°C") +-- No lower limit configured: none emitted. +absent("heating_actuator_8_floor_min_c") + +-- P8 as the hub showed it on 2026-11-11 before the change: the room +-- temperature is the floor sensor's, and the relay is on. +local p8 = HUB_CFMT + :gsub('"InputTemperatureType": "Air"', '"InputTemperatureType": "Floor"') + :gsub('"ControlOutputState": "Off"', '"ControlOutputState": "On"') + :gsub('"PercentageDemand": 0,(%s+)"ControlOutputState"', '"PercentageDemand": 100,%1"ControlOutputState"') + :gsub('"CalculatedTemperature": 242', '"CalculatedTemperature": 263') + :gsub('"FloorTemperatureSensor": {"MaximumTemperature": 300,', + '"FloorTemperatureSensor": {"MaximumTemperature": 300, "MinimumTemperature": 220,') +run(p8) +expect("room_6_temp_c", 26.3, "°C") +expect("room_6_temp_is_floor", 1) +expect("room_6_demand_pct", 100, "%") +expect("room_6_output_on", 1) +expect("heating_actuator_8_floor_min_c", 22.0, "°C") + +-- A floor sensor not in normal operation: its reading is not emitted, +-- the configured limits still are. +run((HUB_CFMT:gsub('"Status": "Normal_Operation"', '"Status": "Open_Circuit"'))) +absent("heating_actuator_8_floor_temp_c") +expect("heating_actuator_8_floor_max_c", 30.0, "°C") + +-- An offline thermostat: the hub's cached floor reading is not fresh. +run((HUB_CFMT:gsub('"RoomId": 6,(%s+)"DisplayedSignalStrength": "VeryGood"', + '"RoomId": 6,%1"DisplayedSignalStrength": "Offline"'))) +absent("heating_actuator_8_floor_temp_c") +absent("heating_actuator_8_floor_max_c") + +-- The sensor type is logged once, and again when it changes. +local function sensor_logs() + local n = 0 + for _, l in ipairs(host._logs) do + if l:find("floor sensor", 1, true) then n = n + 1 end + end + return n +end +run(HUB_CFMT) +driver_poll() +if sensor_logs() ~= 1 then fail("sensor type logged " .. sensor_logs() .. " times, want 1") end +host._http_responses[URL] = (HUB_CFMT:gsub("NTC_15K", "NTC_10K")) +driver_poll() +if sensor_logs() ~= 2 then fail("changed sensor type logged " .. sensor_logs() .. " times, want 2") end + -- The hub does not answer (wrong secret, or offline): nothing is emitted. host.reset() dofile(ROOT .. "drivers/lua/wiser_heat.lua") diff --git a/index.yaml b/index.yaml index 7c95903..bb14bbd 100644 --- a/index.yaml +++ b/index.yaml @@ -830,8 +830,8 @@ drivers: setup: [device_ui] ders: [heatpump] control: false - size_bytes: 8418 - sha256: "ba2295c6b284c7a90254a83ebf8772427f64edfdb2bb245271cb34ca0909606c" + size_bytes: 11712 + sha256: "5b912156eaed6c894a9977689fdfc0fae306ba083e3cc232034e852d52888988" - name: "zap" version: "3.1.3" tier: core diff --git a/manifests/wiser_heat.yaml b/manifests/wiser_heat.yaml index dd56ce8..6ed94ce 100644 --- a/manifests/wiser_heat.yaml +++ b/manifests/wiser_heat.yaml @@ -13,14 +13,21 @@ tested_devices: variants: [WT704R1B30S4] regions: [SE] firmware_versions: "4.50.4" - notes: "Read-only over the hub's local HTTPS API (SECRET header, self-signed certificate pinned via tls_pin_sha256). Tested with one smoke alarm and no thermostat or valve; room temperatures from real thermostats are decoded but not yet seen on hardware." + notes: "Read-only over the hub's local HTTPS API (SECRET header, self-signed certificate pinned via tls_pin_sha256). Tested with smoke alarms and one Renova CFMT 16 A floor thermostat; radiator valves (iTRV) and room stats are not seen on hardware." + min_driver_version: "0.1.0" + - manufacturer: "Schneider Electric" + model_family: "Renova Connected Thermostat 16 A" + variants: [WDE011680] + regions: [SE] + firmware_versions: "1.0.59" + notes: "Read through the Wiser hub, on electric floor heating with a 15 kOhm floor sensor, in presets P8 (floor control) and P6 (room control with floor limit). Room air temperature matched the thermostat display, and the floor sensor (after setting NTC_15K) matched an IR reading of the floor surface; room demand and output state followed the relay through a heat and cool-down cycle. The floor lower limit is decoded but was not configured." min_driver_version: "0.1.0" min_host_version: "2.0.0" -size_bytes: 8418 +size_bytes: 11712 dkb_id: "wiser_heat" -sha256: "ba2295c6b284c7a90254a83ebf8772427f64edfdb2bb245271cb34ca0909606c" +sha256: "5b912156eaed6c894a9977689fdfc0fae306ba083e3cc232034e852d52888988" signature: "" bytecode_sha256: "" bytecode_signature: "" bytecode_size: 0 -changelog: "First version: read-only Wiser hub rooms, smoke alarm temperatures and heating channel demand." +changelog: "First version: read-only Wiser hub rooms, floor thermostats, smoke alarm temperatures and heating channel demand." From 17989dfb9fe5418a68d49251db249b7b24c2022f Mon Sep 17 00:00:00 2001 From: hannesb90 Date: Wed, 11 Nov 2026 18:33:15 +0100 Subject: [PATCH 4/6] fix(wiser_heat): build the hub URL only from host, always HTTPS The base_url test hook could also be set in a site's config. The request carries the hub's secret, and FTW lets a driver with an empty allowed_hosts reach any address, so a base_url could send the secret elsewhere or over clear-text HTTP. The URL is now always https://[:port]/data/domain/, and the tests configure host instead. Co-Authored-By: Claude Opus 5.5 Signed-off-by: hannesb90 --- drivers/lua/wiser_heat.lua | 14 +++++++------ drivers/tests/lua_harness/test_wiser_heat.lua | 20 ++++++++++++++++--- index.yaml | 4 ++-- manifests/wiser_heat.yaml | 4 ++-- 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/drivers/lua/wiser_heat.lua b/drivers/lua/wiser_heat.lua index 20c0bde..c5bce5c 100644 --- a/drivers/lua/wiser_heat.lua +++ b/drivers/lua/wiser_heat.lua @@ -167,16 +167,18 @@ function driver_init(config) host.set_make("Wiser") config = config or {} local function s(v) return (v ~= nil and v ~= "") and tostring(v) or nil end - -- base_url override exists for tests; production builds it from host. - local base = s(config.base_url) - if not base and s(config.host) then + -- The URL is always built from host, and always HTTPS: the request + -- carries the hub's secret, and an empty allowed_hosts lets the host + -- reach any address, so no config value may point it elsewhere or at + -- clear-text HTTP. + if s(config.host) then -- The hub serves HTTPS on 443. A port is added only when the owner -- configured another, so allowed_hosts can stay the bare address. local port = s(config.port) - base = "https://" .. s(config.host) - if port and port ~= "443" then base = base .. ":" .. port end + url = "https://" .. s(config.host) + if port and port ~= "443" then url = url .. ":" .. port end + url = url .. "/data/domain/" end - if base then url = base .. "/data/domain/" end local secret = s(config.secret) if secret then headers = { SECRET = secret, Accept = "application/json" } end if not url then host.log("warn", "Wiser: no host configured") end diff --git a/drivers/tests/lua_harness/test_wiser_heat.lua b/drivers/tests/lua_harness/test_wiser_heat.lua index 780e229..e4aef87 100644 --- a/drivers/tests/lua_harness/test_wiser_heat.lua +++ b/drivers/tests/lua_harness/test_wiser_heat.lua @@ -66,7 +66,7 @@ local function run(body, config) host.reset() host._http_responses[URL] = body dofile(ROOT .. "drivers/lua/wiser_heat.lua") - driver_init(config or { base_url = "https://hub.test", secret = "s" }) + driver_init(config or { host = "hub.test", secret = "s" }) return driver_poll() end @@ -217,7 +217,7 @@ if sensor_logs() ~= 2 then fail("changed sensor type logged " .. sensor_logs() . -- The hub does not answer (wrong secret, or offline): nothing is emitted. host.reset() dofile(ROOT .. "drivers/lua/wiser_heat.lua") -driver_init({ base_url = "https://hub.test", secret = "s" }) +driver_init({ host = "hub.test", secret = "s" }) local retry = driver_poll() if retry ~= 30000 then fail("retry interval " .. tostring(retry)) end if set_interval() ~= 30000 then fail("set retry interval " .. tostring(set_interval())) end @@ -230,10 +230,24 @@ run("Bad request") absent("smoke_alarm_1_temp_c") expect("wiser_poll_ok", 0) +-- A base_url in config is not honoured: the secret goes only to +-- https:///. +host.reset() +host._http_responses[URL] = HUB_R_GEN2 +dofile(ROOT .. "drivers/lua/wiser_heat.lua") +driver_init({ host = "hub.test", base_url = "http://elsewhere.test", secret = "s" }) +driver_poll() +for _, c in ipairs(host._calls or {}) do + if c.func == "http_get" and c.args[1] ~= URL then + fail("called " .. tostring(c.args[1]) .. ", want " .. URL) + end +end +expect("wiser_poll_ok", 1) + -- No secret: the driver does not call the hub at all. host.reset() dofile(ROOT .. "drivers/lua/wiser_heat.lua") -driver_init({ base_url = "https://hub.test" }) +driver_init({ host = "hub.test" }) driver_poll() for _, c in ipairs(host._calls or {}) do if c.func == "http_get" then fail("called the hub without a secret") end diff --git a/index.yaml b/index.yaml index bb14bbd..7038408 100644 --- a/index.yaml +++ b/index.yaml @@ -830,8 +830,8 @@ drivers: setup: [device_ui] ders: [heatpump] control: false - size_bytes: 11712 - sha256: "5b912156eaed6c894a9977689fdfc0fae306ba083e3cc232034e852d52888988" + size_bytes: 11815 + sha256: "fb45f9a1fdae2f3d8583843ea3a995b5d990a16ab4bb378200ccbe7fed4cf2fa" - name: "zap" version: "3.1.3" tier: core diff --git a/manifests/wiser_heat.yaml b/manifests/wiser_heat.yaml index 6ed94ce..0f928ac 100644 --- a/manifests/wiser_heat.yaml +++ b/manifests/wiser_heat.yaml @@ -23,9 +23,9 @@ tested_devices: notes: "Read through the Wiser hub, on electric floor heating with a 15 kOhm floor sensor, in presets P8 (floor control) and P6 (room control with floor limit). Room air temperature matched the thermostat display, and the floor sensor (after setting NTC_15K) matched an IR reading of the floor surface; room demand and output state followed the relay through a heat and cool-down cycle. The floor lower limit is decoded but was not configured." min_driver_version: "0.1.0" min_host_version: "2.0.0" -size_bytes: 11712 +size_bytes: 11815 dkb_id: "wiser_heat" -sha256: "5b912156eaed6c894a9977689fdfc0fae306ba083e3cc232034e852d52888988" +sha256: "fb45f9a1fdae2f3d8583843ea3a995b5d990a16ab4bb378200ccbe7fed4cf2fa" signature: "" bytecode_sha256: "" bytecode_signature: "" From 79f8e2adc5f422e882b438f84281b0cfd2878727 Mon Sep 17 00:00:00 2001 From: hannesb90 Date: Wed, 11 Nov 2026 19:28:30 +0100 Subject: [PATCH 5/6] feat(wiser_heat): estimate floor heating power from a stated load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Renova CFMT switches its load with a relay and measures nothing: the hub reports InstantaneousDemand 0 with the relay on. The owner can now state the load per actuator, as rated_w or as area_m2 x w_per_m2 (default 110 W/m²), and the driver emits heating_actuator__power_est_w, the load times the room's PercentageDemand. The thermostat runs the relay time-proportionally over a ten-minute cycle, so that is the average draw over the cycle. It is a metric, not a power stream, so an estimate never enters a measured site balance. A load outside 16 A x 230 V, an area over 200 m² or over 300 W/m² is refused and logged. Nothing is emitted for an actuator with no stated load, an offline thermostat, or a poll the hub did not answer. Co-Authored-By: Claude Opus 5.5 Signed-off-by: hannesb90 --- CHANGELOG.md | 6 ++ devices.yaml | 2 +- drivers/lua/wiser_heat.lua | 70 +++++++++++++++++++ drivers/tests/lua_harness/test_wiser_heat.lua | 45 ++++++++++++ index.yaml | 4 +- manifests/wiser_heat.yaml | 6 +- 6 files changed, 127 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a264896..7bba9a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ or air. Checked on a Renova CFMT 16 A in presets P8 and P6. The hub reports the configured floor sensor type but cannot see the probe: a 15 kOhm probe set as 10 kOhm read about 9 °C low. The driver logs the type when it changes. +A floor thermostat measures no power, so the owner can state its load per +actuator, as `rated_w` or as `area_m2` times `w_per_m2` (default 110 W/m²). +The driver then emits `heating_actuator__power_est_w`, the load times the +room's demand: the average draw over the relay's cycle. It is a metric, not a +power stream, so it never enters a measured site balance. + ### easee_cloud 1.4.1 The driver says its session energy is coarse, with `session_wh_coarse`. Easee diff --git a/devices.yaml b/devices.yaml index b834b64..86c928a 100644 --- a/devices.yaml +++ b/devices.yaml @@ -1248,7 +1248,7 @@ manufacturers: ders: [heatpump] control: false firmware_versions: "1.0.59" - notes: "Read through the Wiser hub, on electric floor heating with a 15 kOhm floor sensor, in presets P8 (floor control) and P6 (room control with floor limit). Room air temperature matched the thermostat display, and the floor sensor (after setting NTC_15K) matched an IR reading of the floor surface; room demand and output state followed the relay through a heat and cool-down cycle. The floor lower limit is decoded but was not configured." + notes: "Read through the Wiser hub, on electric floor heating with a 15 kOhm floor sensor, in presets P8 (floor control) and P6 (room control with floor limit). Room air temperature matched the thermostat display, and the floor sensor (after setting NTC_15K) matched an IR reading of the floor surface; room demand and output state followed the relay through a heat and cool-down cycle. The floor lower limit is decoded but was not configured. The thermostat measures no power; the power estimate is the owner's stated load times the room demand." - name: "Wiser Hub R gen 2" variants: [WT704R1B30S4] regions: [SE] diff --git a/drivers/lua/wiser_heat.lua b/drivers/lua/wiser_heat.lua index c5bce5c..670a810 100644 --- a/drivers/lua/wiser_heat.lua +++ b/drivers/lua/wiser_heat.lua @@ -18,6 +18,8 @@ -- heating_actuator__floor_temp_c floor sensor, 0.1 °C -- heating_actuator__floor_max_c floor upper limit -- heating_actuator__floor_min_c floor lower limit +-- heating_actuator__power_est_w estimated draw, only +-- when configured (below) -- - each SmokeAlarmDevice: smoke_alarm__temp_c MeasuredTemperature -- - each HeatingChannel: heating_channel__demand_pct PercentageDemand -- heating_channel__relay_on HeatingRelayState @@ -53,6 +55,21 @@ -- floor reading is emitted only while the sensor status is -- Normal_Operation. -- +-- ESTIMATED POWER +-- A Renova CFMT switches its load with a relay and measures no power: +-- the hub reports InstantaneousDemand and CurrentSummationDelivered as 0 +-- with the relay on. When the owner states the load, the driver emits +-- heating_actuator__power_est_w = rated W x room PercentageDemand. +-- The thermostat drives the relay time-proportionally over its cycle +-- (CycleTime_10, ten minutes), so the demand is the share of the cycle the +-- relay is on, and the product is the average draw over the cycle, not +-- the instant one. It is a metric, not a power stream: it is an estimate +-- and must not be added to a measured site balance. The rated W is, per +-- actuator id, either rated_w (the heating cable or mat's own rating, best +-- when known) or area_m2 x w_per_m2 (w_per_m2 defaults to 110, a common +-- rating for floor heating mats in bathrooms). Nothing is emitted for an +-- actuator without a configured load. +-- -- STALENESS -- The hub answers from its own cache. A device whose Device entry shows -- DisplayedSignalStrength "Offline" or "NoSignal" has its temperature @@ -74,6 +91,11 @@ -- config: -- host: "" -- secret: "" # masked via config_secrets +-- floor_heating: # optional, per heating actuator id +-- - actuator: 8 +-- area_m2: 4.5 # x w_per_m2 (default 110) +-- # w_per_m2: 130 # the owner's own rating per m² +-- # rated_w: 600 # or the cable's total rating -- capabilities: -- http: -- allowed_hosts: [""] @@ -110,11 +132,19 @@ local SETPOINT_OFF = -200 -- -20.0 °C, off or no control local POLL_MS = 60000 local RETRY_MS = 30000 +local DEFAULT_W_PER_M2 = 110 +-- Bounds on a configured load. 16 A x 230 V is the most a CFMT relay +-- switches; a figure outside these is a typing error, not a floor. +local MAX_RATED_W = 3680 +local MAX_AREA_M2 = 200 +local MAX_W_PER_M2 = 300 + local function next_poll(ms) host.set_poll_interval(ms) return ms end +local rated_w = {} -- actuator id -> configured load in W local url = nil local headers = nil local identity = false @@ -179,6 +209,35 @@ function driver_init(config) if port and port ~= "443" then url = url .. ":" .. port end url = url .. "/data/domain/" end + rated_w = {} + local fh = config.floor_heating + if type(fh) == "table" then + for _, e in ipairs(fh) do + local id = type(e) == "table" and tonumber(e.actuator) or nil + local w = nil + if id then + local rw, area = tonumber(e.rated_w), tonumber(e.area_m2) + local per = tonumber(e.w_per_m2) or DEFAULT_W_PER_M2 + if rw ~= nil then + if rw > 0 and rw <= MAX_RATED_W then w = rw end + elseif area ~= nil and area > 0 and area <= MAX_AREA_M2 + and per > 0 and per <= MAX_W_PER_M2 then + w = area * per + if w > MAX_RATED_W then w = nil end + end + end + if w then + rated_w[id] = w + host.log("info", string.format( + "Wiser: heating actuator %d load %.0f W (estimate)", id, w)) + else + host.log("warn", "Wiser: floor_heating entry ignored: needs actuator and " + .. "rated_w (1-" .. MAX_RATED_W .. " W) or area_m2 (1-" .. MAX_AREA_M2 + .. " m2) with w_per_m2 (1-" .. MAX_W_PER_M2 .. ")") + end + end + end + local secret = s(config.secret) if secret then headers = { SECRET = secret, Accept = "application/json" } end if not url then host.log("warn", "Wiser: no host configured") end @@ -211,9 +270,11 @@ function driver_poll() if d.id ~= nil then devices[d.id] = d end end + local room_demand = {} for _, r in ipairs(data.Room or {}) do local id = tonumber(r.id) if id then + room_demand[id] = tonumber(r.PercentageDemand) emit("room_" .. id .. "_temp_c", temp(r.CalculatedTemperature), "°C") local sp = tonumber(r.CurrentSetPoint) if sp ~= nil and sp > SETPOINT_OFF then @@ -245,6 +306,15 @@ function driver_poll() emit(p .. "_floor_max_c", temp(f.MaximumTemperature), "°C") emit(p .. "_floor_min_c", temp(f.MinimumTemperature), "°C") end + local w = id and rated_w[id] + if w and device_online(devices, a.DeviceId) then + local dev = devices[a.DeviceId] + local demand = room_demand[tonumber(dev.RoomId)] + if demand ~= nil and demand >= 0 and demand <= 100 then + emit("heating_actuator_" .. id .. "_power_est_w", + math.floor(w * demand / 100 + 0.5), "W") + end + end end for _, s in ipairs(data.SmokeAlarmDevice or {}) do diff --git a/drivers/tests/lua_harness/test_wiser_heat.lua b/drivers/tests/lua_harness/test_wiser_heat.lua index e4aef87..7f6ed4f 100644 --- a/drivers/tests/lua_harness/test_wiser_heat.lua +++ b/drivers/tests/lua_harness/test_wiser_heat.lua @@ -199,6 +199,51 @@ run((HUB_CFMT:gsub('"RoomId": 6,(%s+)"DisplayedSignalStrength": "VeryGood"', absent("heating_actuator_8_floor_temp_c") absent("heating_actuator_8_floor_max_c") +-- Estimated power: none without a configured load. +run(HUB_CFMT) +absent("heating_actuator_8_power_est_w") + +local function with_load(body, entry) + return run(body, { host = "hub.test", secret = "s", floor_heating = { entry } }) +end +local demand45 = (HUB_CFMT:gsub('"PercentageDemand": 0,(%s+)"ControlOutputState"', + '"PercentageDemand": 45,%1"ControlOutputState"')) + +-- area_m2 x the default 110 W/m2, times the room's demand. +with_load(demand45, { actuator = 8, area_m2 = 4.5 }) +expect("heating_actuator_8_power_est_w", 223, "W") -- 4.5 x 110 x 0.45 +-- The owner's own W/m2. +with_load(demand45, { actuator = 8, area_m2 = 4.5, w_per_m2 = 150 }) +expect("heating_actuator_8_power_est_w", 304, "W") -- 675 x 0.45 +-- The cable's own rating wins over the area. +with_load(demand45, { actuator = 8, area_m2 = 4.5, rated_w = 600 }) +expect("heating_actuator_8_power_est_w", 270, "W") +-- Demand 0: 0 W is a reading here, the relay is off for the whole cycle. +with_load(HUB_CFMT, { actuator = 8, area_m2 = 4.5 }) +expect("heating_actuator_8_power_est_w", 0, "W") +-- Full demand. +with_load(p8, { actuator = 8, rated_w = 600 }) +expect("heating_actuator_8_power_est_w", 600, "W") +-- A typing error is refused, not emitted. +with_load(demand45, { actuator = 8, area_m2 = 450 }) +absent("heating_actuator_8_power_est_w") +with_load(demand45, { actuator = 8, rated_w = 60000 }) +absent("heating_actuator_8_power_est_w") +-- Another actuator's load does not apply to this one. +with_load(demand45, { actuator = 9, area_m2 = 4.5 }) +absent("heating_actuator_8_power_est_w") +-- An offline thermostat: no estimate from the hub's cached demand. +with_load((demand45:gsub('"RoomId": 6,(%s+)"DisplayedSignalStrength": "VeryGood"', + '"RoomId": 6,%1"DisplayedSignalStrength": "Offline"')), + { actuator = 8, area_m2 = 4.5 }) +absent("heating_actuator_8_power_est_w") +-- The hub does not answer: nothing, estimate included. +host.reset() +dofile(ROOT .. "drivers/lua/wiser_heat.lua") +driver_init({ host = "hub.test", secret = "s", floor_heating = { { actuator = 8, area_m2 = 4.5 } } }) +driver_poll() +absent("heating_actuator_8_power_est_w") + -- The sensor type is logged once, and again when it changes. local function sensor_logs() local n = 0 diff --git a/index.yaml b/index.yaml index 7038408..2f1bf6f 100644 --- a/index.yaml +++ b/index.yaml @@ -830,8 +830,8 @@ drivers: setup: [device_ui] ders: [heatpump] control: false - size_bytes: 11815 - sha256: "fb45f9a1fdae2f3d8583843ea3a995b5d990a16ab4bb378200ccbe7fed4cf2fa" + size_bytes: 15312 + sha256: "a4001d43a2a3688c94e3cceed33a5537e3d74b1e79e70ddbc5f9309418b78dc0" - name: "zap" version: "3.1.3" tier: core diff --git a/manifests/wiser_heat.yaml b/manifests/wiser_heat.yaml index 0f928ac..9456e86 100644 --- a/manifests/wiser_heat.yaml +++ b/manifests/wiser_heat.yaml @@ -20,12 +20,12 @@ tested_devices: variants: [WDE011680] regions: [SE] firmware_versions: "1.0.59" - notes: "Read through the Wiser hub, on electric floor heating with a 15 kOhm floor sensor, in presets P8 (floor control) and P6 (room control with floor limit). Room air temperature matched the thermostat display, and the floor sensor (after setting NTC_15K) matched an IR reading of the floor surface; room demand and output state followed the relay through a heat and cool-down cycle. The floor lower limit is decoded but was not configured." + notes: "Read through the Wiser hub, on electric floor heating with a 15 kOhm floor sensor, in presets P8 (floor control) and P6 (room control with floor limit). Room air temperature matched the thermostat display, and the floor sensor (after setting NTC_15K) matched an IR reading of the floor surface; room demand and output state followed the relay through a heat and cool-down cycle. The floor lower limit is decoded but was not configured. The thermostat measures no power; the power estimate is the owner's stated load times the room demand." min_driver_version: "0.1.0" min_host_version: "2.0.0" -size_bytes: 11815 +size_bytes: 15312 dkb_id: "wiser_heat" -sha256: "fb45f9a1fdae2f3d8583843ea3a995b5d990a16ab4bb378200ccbe7fed4cf2fa" +sha256: "a4001d43a2a3688c94e3cceed33a5537e3d74b1e79e70ddbc5f9309418b78dc0" signature: "" bytecode_sha256: "" bytecode_signature: "" From 93d6cac36e835eef120a6d757ba59ce3ef647f92 Mon Sep 17 00:00:00 2001 From: hannesb90 <279736760+hannesb90@users.noreply.github.com> Date: Wed, 11 Nov 2026 22:44:06 +0100 Subject: [PATCH 6/6] fix(wiser_heat): keep the floor reading while the floor limit holds the output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Renova CFMT held off by its floor limit reports Output_Thermal_Limit, and its probe still reads (23.8 °C on the entrance floor on 2026-11-11). The driver dropped the floor temperature exactly then. It is now kept, and heating_actuator__floor_limit_active says the limit is holding. Also documented from the same site: the air setpoint range follows the floor limits, notes for a later timed-override control, and three thermostats with 10, 12 and 15 kOhm probes in the verification notes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +- devices.yaml | 4 +- drivers/lua/wiser_heat.lua | 45 +++++++++++++++++-- drivers/tests/lua_harness/test_wiser_heat.lua | 10 +++++ index.yaml | 4 +- manifests/wiser_heat.yaml | 4 +- 6 files changed, 60 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bba9a5..b54d733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,9 @@ demand and output state, each floor thermostat's floor sensor temperature and floor limits, each smoke alarm's temperature, and each heating channel's demand and relay state, from one GET of `/data/domain/` per minute. The hub's secret is a masked config value, and its self-signed certificate is pinned with -`tls_pin_sha256`. +`tls_pin_sha256`. A floor thermostat's floor temperature is kept while its floor limit +holds the output off (`Output_Thermal_Limit`), and +`heating_actuator__floor_limit_active` says so. A room with nothing assigned reports -32768 and a setpoint of -20.0 °C, and neither is emitted. A device the hub shows as offline has its temperature diff --git a/devices.yaml b/devices.yaml index 86c928a..5cecbc5 100644 --- a/devices.yaml +++ b/devices.yaml @@ -1248,7 +1248,7 @@ manufacturers: ders: [heatpump] control: false firmware_versions: "1.0.59" - notes: "Read through the Wiser hub, on electric floor heating with a 15 kOhm floor sensor, in presets P8 (floor control) and P6 (room control with floor limit). Room air temperature matched the thermostat display, and the floor sensor (after setting NTC_15K) matched an IR reading of the floor surface; room demand and output state followed the relay through a heat and cool-down cycle. The floor lower limit is decoded but was not configured. The thermostat measures no power; the power estimate is the owner's stated load times the room demand." + notes: "Read through the Wiser hub, on electric floor heating under tiles, in presets P8 (floor control) and P6 (room control with floor limit), on three thermostats with 10, 12 and 15 kOhm floor sensors. Room air temperature matched the thermostat display, and each floor sensor, set to its own type, read within 0.7 °C of an IR reading of the floor surface; one was seen held off by its floor limit (Output_Thermal_Limit) while its probe still read; room demand and output state followed the relay through a heat and cool-down cycle. The floor lower limit is decoded but was not configured. The thermostat measures no power; the power estimate is the owner's stated load times the room demand." - name: "Wiser Hub R gen 2" variants: [WT704R1B30S4] regions: [SE] @@ -1259,7 +1259,7 @@ manufacturers: ders: [heatpump] control: false firmware_versions: "4.50.4" - notes: "Read-only over the hub's local HTTPS API (SECRET header, self-signed certificate pinned via tls_pin_sha256). Tested with smoke alarms and one Renova CFMT 16 A floor thermostat; radiator valves (iTRV) and room stats are not seen on hardware." + notes: "Read-only over the hub's local HTTPS API (SECRET header, self-signed certificate pinned via tls_pin_sha256). Tested with smoke alarms and three Renova CFMT 16 A floor thermostats; radiator valves (iTRV) and room stats are not seen on hardware." - name: "iEM3xxx/PM5xxx" variants: [PM5100, PM5110, PM5300, PM5310, PM5330, PM5340, PM710, iEM3150, iEM3155, iEM3250, iEM3255, iEM3350, iEM3355] regions: [] diff --git a/drivers/lua/wiser_heat.lua b/drivers/lua/wiser_heat.lua index 670a810..13d9319 100644 --- a/drivers/lua/wiser_heat.lua +++ b/drivers/lua/wiser_heat.lua @@ -18,6 +18,9 @@ -- heating_actuator__floor_temp_c floor sensor, 0.1 °C -- heating_actuator__floor_max_c floor upper limit -- heating_actuator__floor_min_c floor lower limit +-- heating_actuator__floor_limit_active 1 while the +-- floor limit holds +-- the output off -- heating_actuator__power_est_w estimated draw, only -- when configured (below) -- - each SmokeAlarmDevice: smoke_alarm__temp_c MeasuredTemperature @@ -52,8 +55,27 @@ -- on the test site a 15 kOhm probe configured as 10 kOhm read 17.4 °C -- while the floor surface measured 26.6-27.5 °C, and after setting -- NTC_15K it read 27.0 °C. The type is logged when it changes, and the --- floor reading is emitted only while the sensor status is --- Normal_Operation. +-- floor reading is emitted only while the sensor status says the probe +-- reads: Normal_Operation, or Output_Thermal_Limit (below). +-- On 2026-11-11 three CFMTs on one hub had NTC_10K, NTC_12K and NTC_15K +-- probes; with each set to its own type, all three read within 0.7 °C of +-- the tiled floor surface measured with an IR thermometer. +-- +-- FLOOR LIMIT +-- When the floor limit holds the output off, the sensor status is +-- Output_Thermal_Limit and the room's FloorSensorState is BeyondMaximum. +-- The probe still reads (23.8 °C on 2026-11-11, a valid reading), so the +-- floor temperature is kept and floor_limit_active says the output is +-- being held off by the limit, not by the room's own demand. +-- +-- SETPOINT RANGE FOLLOWS THE FLOOR LIMITS +-- With a floor sensor fitted, the hub sets the room's air setpoint range +-- (ClimateCapabilities MinimumHeatSetpoint/MaximumHeatSetpoint) to the +-- floor limits. With the lower floor limit at 22 °C the room could not be +-- set under 22 °C air, and a 20 °C schedule ran at 22 °C. Lowering the +-- floor limit to 19 °C in the app moved the air minimum to 19 °C with it. +-- room__setpoint_c is therefore the effective setpoint, which can be +-- above what the schedule asks for. -- -- ESTIMATED POWER -- A Renova CFMT switches its load with a relay and measures no power: @@ -70,6 +92,20 @@ -- rating for floor heating mats in bathrooms). Nothing is emitted for an -- actuator without a configured load. -- +-- NOTES FOR A LATER CONTROL (not used: this driver is read-only) +-- Measured on 2026-11-11, firmware 4.50.4: +-- - PATCH /data/v2/domain/Room/ with {"RequestOverride": {"Type": +-- "Manual", "SetPoint": <0.1 °C>, "DurationMinutes": }} sets a timed +-- override. The hub rounds its end (OverrideTimeoutUnixTime) to a +-- quarter hour, and when it ends the room returns by itself to its +-- manual setpoint or schedule: a device-side timed hold. +-- {"RequestOverride": {"Type": "None"}} cancels it. +-- - The floor limits cannot be written locally: a PATCH of +-- HeatingActuator FloorTemperatureSensor.MinimumTemperature answers +-- 200 and changes nothing. Only the app sets them. +-- - A room whose schedule is removed goes to Manual mode at -200 (off), +-- not to its last setpoint. +-- -- STALENESS -- The hub answers from its own cache. A device whose Device entry shows -- DisplayedSignalStrength "Offline" or "NoSignal" has its temperature @@ -116,7 +152,7 @@ DRIVER = { authors = { "hannesb90", "Claude (Anthropic)" }, tested_models = { "Wiser Hub R gen 2 (WT704R1B30S4)", "Renova Connected Thermostat 16 A (WDE011680)" }, verification_status = "experimental", - verification_notes = "Checked against one Wiser Hub R gen 2 (WT704R1B30S4, firmware 4.50.4) with smoke alarms and one Renova CFMT 16 A floor thermostat (WDE011680) on electric floor heating, on 2026-11-10 and 2026-11-11, in both the floor-control (P8) and room-with-floor-limit (P6) presets. Radiator valves (iTRV) and room stats are decoded only as rooms; not seen.", + verification_notes = "Checked against one Wiser Hub R gen 2 (WT704R1B30S4, firmware 4.50.4) with smoke alarms and three Renova CFMT 16 A floor thermostats (WDE011680) on electric floor heating under tiles, on 2026-11-10 and 2026-11-11, in both the floor-control (P8) and room-with-floor-limit (P6) presets. Their NTC_10K, NTC_12K and NTC_15K floor probes read within 0.7 °C of the measured floor surface, and one was seen held off by its floor limit (Output_Thermal_Limit). Radiator valves (iTRV) and room stats are decoded only as rooms; not seen.", config_secrets = { "secret" }, } @@ -300,8 +336,9 @@ function driver_poll() .. tostring(f.SensorType) .. ", status " .. tostring(f.Status)) end local p = "heating_actuator_" .. id - if f.Status == "Normal_Operation" then + if f.Status == "Normal_Operation" or f.Status == "Output_Thermal_Limit" then emit(p .. "_floor_temp_c", temp(f.MeasuredTemperature), "°C") + emit(p .. "_floor_limit_active", f.Status == "Output_Thermal_Limit" and 1 or 0, "") end emit(p .. "_floor_max_c", temp(f.MaximumTemperature), "°C") emit(p .. "_floor_min_c", temp(f.MinimumTemperature), "°C") diff --git a/drivers/tests/lua_harness/test_wiser_heat.lua b/drivers/tests/lua_harness/test_wiser_heat.lua index 7f6ed4f..ae257ff 100644 --- a/drivers/tests/lua_harness/test_wiser_heat.lua +++ b/drivers/tests/lua_harness/test_wiser_heat.lua @@ -187,11 +187,21 @@ expect("room_6_demand_pct", 100, "%") expect("room_6_output_on", 1) expect("heating_actuator_8_floor_min_c", 22.0, "°C") +expect("heating_actuator_8_floor_limit_active", 0) + +-- The floor limit holds the output off (entrance, 2026-11-11): the probe +-- still reads, so the floor temperature stays and the limit is flagged. +run((HUB_CFMT:gsub('"Status": "Normal_Operation"', '"Status": "Output_Thermal_Limit"') + :gsub('"FloorSensorState": "InRange"', '"FloorSensorState": "BeyondMaximum"'))) +expect("heating_actuator_8_floor_temp_c", 26.3, "°C") +expect("heating_actuator_8_floor_limit_active", 1) + -- A floor sensor not in normal operation: its reading is not emitted, -- the configured limits still are. run((HUB_CFMT:gsub('"Status": "Normal_Operation"', '"Status": "Open_Circuit"'))) absent("heating_actuator_8_floor_temp_c") expect("heating_actuator_8_floor_max_c", 30.0, "°C") +absent("heating_actuator_8_floor_limit_active") -- An offline thermostat: the hub's cached floor reading is not fresh. run((HUB_CFMT:gsub('"RoomId": 6,(%s+)"DisplayedSignalStrength": "VeryGood"', diff --git a/index.yaml b/index.yaml index 2f1bf6f..2e72b3f 100644 --- a/index.yaml +++ b/index.yaml @@ -830,8 +830,8 @@ drivers: setup: [device_ui] ders: [heatpump] control: false - size_bytes: 15312 - sha256: "a4001d43a2a3688c94e3cceed33a5537e3d74b1e79e70ddbc5f9309418b78dc0" + size_bytes: 17864 + sha256: "d6df3e516ce4c9a21ace475ddf6a6fbca874d27e3fe3461d6851d64828bd2c7b" - name: "zap" version: "3.1.3" tier: core diff --git a/manifests/wiser_heat.yaml b/manifests/wiser_heat.yaml index 9456e86..cb52d11 100644 --- a/manifests/wiser_heat.yaml +++ b/manifests/wiser_heat.yaml @@ -13,14 +13,14 @@ tested_devices: variants: [WT704R1B30S4] regions: [SE] firmware_versions: "4.50.4" - notes: "Read-only over the hub's local HTTPS API (SECRET header, self-signed certificate pinned via tls_pin_sha256). Tested with smoke alarms and one Renova CFMT 16 A floor thermostat; radiator valves (iTRV) and room stats are not seen on hardware." + notes: "Read-only over the hub's local HTTPS API (SECRET header, self-signed certificate pinned via tls_pin_sha256). Tested with smoke alarms and three Renova CFMT 16 A floor thermostats; radiator valves (iTRV) and room stats are not seen on hardware." min_driver_version: "0.1.0" - manufacturer: "Schneider Electric" model_family: "Renova Connected Thermostat 16 A" variants: [WDE011680] regions: [SE] firmware_versions: "1.0.59" - notes: "Read through the Wiser hub, on electric floor heating with a 15 kOhm floor sensor, in presets P8 (floor control) and P6 (room control with floor limit). Room air temperature matched the thermostat display, and the floor sensor (after setting NTC_15K) matched an IR reading of the floor surface; room demand and output state followed the relay through a heat and cool-down cycle. The floor lower limit is decoded but was not configured. The thermostat measures no power; the power estimate is the owner's stated load times the room demand." + notes: "Read through the Wiser hub, on electric floor heating under tiles, in presets P8 (floor control) and P6 (room control with floor limit), on three thermostats with 10, 12 and 15 kOhm floor sensors. Room air temperature matched the thermostat display, and each floor sensor, set to its own type, read within 0.7 °C of an IR reading of the floor surface; one was seen held off by its floor limit (Output_Thermal_Limit) while its probe still read; room demand and output state followed the relay through a heat and cool-down cycle. The floor lower limit is decoded but was not configured. The thermostat measures no power; the power estimate is the owner's stated load times the room demand." min_driver_version: "0.1.0" min_host_version: "2.0.0" size_bytes: 15312