diff --git a/CHANGELOG.md b/CHANGELOG.md index aaf58aa..b54d733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ ## [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, 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`. 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 +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. + +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/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..5cecbc5 100644 --- a/devices.yaml +++ b/devices.yaml @@ -1238,6 +1238,28 @@ 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 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] + 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 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 new file mode 100644 index 0000000..13d9319 --- /dev/null +++ b/drivers/lua/wiser_heat.lua @@ -0,0 +1,381 @@ +-- 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 +-- 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 +-- 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 +-- - 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. +-- +-- 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 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: +-- 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. +-- +-- 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 +-- 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 +-- 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: [""] +-- 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, 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)", "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 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" }, +} + +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 + +-- 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 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 + +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 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] + -- 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 + -- 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) + url = "https://" .. s(config.host) + 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 + if not headers then host.log("warn", "Wiser: no secret configured") end +end + +function driver_poll() + 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 + 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 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 next_poll(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 + + 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 + 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" 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") + 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 + 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 next_poll(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..ae257ff --- /dev/null +++ b/drivers/tests/lua_harness/test_wiser_heat.lua @@ -0,0 +1,316 @@ +-- 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 { host = "hub.test", secret = "s" }) + return driver_poll() +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 +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, "%") + +-- 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") + +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"', + '"RoomId": 6,%1"DisplayedSignalStrength": "Offline"'))) +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 + 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") +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 +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) + +-- 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({ 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 +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..2e72b3f 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: 17864 + sha256: "d6df3e516ce4c9a21ace475ddf6a6fbca874d27e3fe3461d6851d64828bd2c7b" - 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..cb52d11 --- /dev/null +++ b/manifests/wiser_heat.yaml @@ -0,0 +1,33 @@ +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 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 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 +dkb_id: "wiser_heat" +sha256: "a4001d43a2a3688c94e3cceed33a5537e3d74b1e79e70ddbc5f9309418b78dc0" +signature: "" +bytecode_sha256: "" +bytecode_signature: "" +bytecode_size: 0 +changelog: "First version: read-only Wiser hub rooms, floor thermostats, 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",