From d08b5d9ca959378099dda4a801922980aec3a28d Mon Sep 17 00:00:00 2001 From: yezi289 <1028952026@qq.com> Date: Mon, 26 Oct 2026 21:08:12 +0800 Subject: [PATCH 1/6] [WWSTCERT-13462, WWSTCERT-13465] Sonoff SWV2C valve support (#3171) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat:sonoff swv2c valve * Zigbee Value: 1、Add Sonoff valve subdriver lazy loading. 2、Modify comment language and file name * Address SWV2C review feedback * Configure SWV2C reporting and fix integration tests * Remove duplicate Zigbee valve health check setting * fix: guard SWV2C endpoint 2 reports until child exists * fix: handle SWV2C identify reports and use default battery reporting * Remove redundant power source capability from SWV2C * Reuse valve battery profile for SWV2C --------- Co-authored-by: Konrad K <33450498+KKlimczukS@users.noreply.github.com> --- .../SmartThings/zigbee-valve/fingerprints.yml | 10 + .../zigbee-valve/profiles/valve.yml | 10 + .../zigbee-valve/src/sonoff/can_handle.lua | 4 +- .../zigbee-valve/src/sonoff/init.lua | 124 ++++-- .../src/test/test_sonoff_valve.lua | 12 + .../src/test/test_sonoff_valve_2.lua | 405 ++++++++++++++++++ 6 files changed, 537 insertions(+), 28 deletions(-) create mode 100644 drivers/SmartThings/zigbee-valve/profiles/valve.yml create mode 100644 drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve_2.lua diff --git a/drivers/SmartThings/zigbee-valve/fingerprints.yml b/drivers/SmartThings/zigbee-valve/fingerprints.yml index 31ea03cd3e..832ff016a2 100644 --- a/drivers/SmartThings/zigbee-valve/fingerprints.yml +++ b/drivers/SmartThings/zigbee-valve/fingerprints.yml @@ -59,3 +59,13 @@ zigbeeManufacturer : manufacturer: SONOFF model: SWV-ZNU deviceProfileName: valve-battery + - id: SONOFF/SWV-ZF2U + deviceLabel: SONOFF Water Valve 2 + manufacturer: SONOFF + model: SWV-ZF2U + deviceProfileName: valve-battery + - id: SONOFF/SWV-ZF2 + deviceLabel: SONOFF Water Valve 2 + manufacturer: SONOFF + model: SWV-ZF2 + deviceProfileName: valve-battery diff --git a/drivers/SmartThings/zigbee-valve/profiles/valve.yml b/drivers/SmartThings/zigbee-valve/profiles/valve.yml new file mode 100644 index 0000000000..4503c347fe --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/profiles/valve.yml @@ -0,0 +1,10 @@ +name: valve +components: +- id: main + capabilities: + - id: valve + version: 1 + - id: refresh + version: 1 + categories: + - name: WaterValve diff --git a/drivers/SmartThings/zigbee-valve/src/sonoff/can_handle.lua b/drivers/SmartThings/zigbee-valve/src/sonoff/can_handle.lua index 90e56b541c..18174e338a 100644 --- a/drivers/SmartThings/zigbee-valve/src/sonoff/can_handle.lua +++ b/drivers/SmartThings/zigbee-valve/src/sonoff/can_handle.lua @@ -2,10 +2,12 @@ -- Licensed under the Apache License, Version 2.0 local FINGERPRINTS = { + { mfr = "SONOFF", model = "SWV-ZF2U" }, + { mfr = "SONOFF", model = "SWV-ZF2" }, { mfr = "SONOFF", model = "SWV-ZFU" }, { mfr = "SONOFF", model = "SWV-ZFE" }, { mfr = "SONOFF", model = "SWV-ZNU" }, - { mfr = "SONOFF", model = "SWV-ZNE" }, + { mfr = "SONOFF", model = "SWV-ZNE" } } local function sonoff_can_handle(opts, driver, device, ...) diff --git a/drivers/SmartThings/zigbee-valve/src/sonoff/init.lua b/drivers/SmartThings/zigbee-valve/src/sonoff/init.lua index 8bc14a84ef..dd2e6f641b 100644 --- a/drivers/SmartThings/zigbee-valve/src/sonoff/init.lua +++ b/drivers/SmartThings/zigbee-valve/src/sonoff/init.lua @@ -3,47 +3,117 @@ local capabilities = require "st.capabilities" local zcl_clusters = require "st.zigbee.zcl.clusters" +local Identify = zcl_clusters.Identify local OnOff = zcl_clusters.OnOff local PowerConfiguration = zcl_clusters.PowerConfiguration -local utils = require "st.utils" - ---- OnOff Property Reporting Handler → Valve Capability Point Event ---- SONOFF reports valve state through the OnOff cluster, so keep an explicit mapping ---- from OnOff reports to valve events while using the parent driver's default commands. ---- @param driver table Driver instance ---- @param device table Device instance ---- @param value table Zigbee attribute value -local function onoff_attr_handler(driver, device, value) - local is_on = value.value ~= false and value.value ~= 0 - if is_on then - device:emit_event(capabilities.valve.valve.open()) +local st_device = require "st.device" + +local CONFIGURATION = { + { + cluster = PowerConfiguration.ID, + attribute = PowerConfiguration.attributes.BatteryPercentageRemaining.ID, + minimum_interval = 30, + maximum_interval = 21600, + data_type = PowerConfiguration.attributes.BatteryPercentageRemaining.base_type, + reportable_change = 1 + }, + { + cluster = OnOff.ID, + attribute = OnOff.attributes.OnOff.ID, + minimum_interval = 0, + maximum_interval = 600, + data_type = OnOff.attributes.OnOff.base_type + } +} + +local function find_child(parent, ep_id) + return parent:get_child_by_parent_assigned_key(string.format("%02X", ep_id)) +end + +local function is_dual_channel(device) + local model = device:get_model() + return model == "SWV-ZF2U" or model == "SWV-ZF2" +end + +--- Map OnOff reports to the corresponding valve. +local function onoff_attr_handler(driver, device, value, zb_rx) + local ep = zb_rx.address_header.src_endpoint.value + local target = device + if device.network_type == st_device.NETWORK_TYPE_ZIGBEE and is_dual_channel(device) and ep == 0x02 then + target = find_child(device, 2) + if not target then return end + end + if value.value ~= false and value.value ~= 0 then + target:emit_event(capabilities.valve.valve.open()) else - device:emit_event(capabilities.valve.valve.closed()) + target:emit_event(capabilities.valve.valve.closed()) + end +end + +local function device_init(driver, device) + if device.network_type == st_device.NETWORK_TYPE_ZIGBEE then + if is_dual_channel(device) then + device:set_find_child(find_child) + end + for _, attribute in ipairs(CONFIGURATION) do + device:add_configured_attribute(attribute) + end + end +end + +local function do_configure(driver, device) + if device.network_type == st_device.NETWORK_TYPE_ZIGBEE then + device:refresh() + device:configure() end end ---- Battery Percentage Attribute Handler ---- Zigbee BatteryPercentageRemaining range 0-200 (0%-100%), needs to be divided by 2 ---- @param driver table Driver instance ---- @param device table Device instance ---- @param value table Zigbee attribute value -local function battery_percentage_handler(driver, device, value) - local raw = value.value - local percent = utils.round(raw / 2) - device:emit_event(capabilities.battery.battery(percent)) +local function device_added(driver, device) + if device.network_type == st_device.NETWORK_TYPE_ZIGBEE and is_dual_channel(device) then + if find_child(device, 2) == nil then + driver:try_create_device({ + type = "EDGE_CHILD", + label = string.format("%s 2", device.label), + profile = "valve", + parent_device_id = device.id, + parent_assigned_child_key = string.format("%02X", 2), + vendor_provided_label = string.format("%s 2", device.label), + }) + end + end + device:refresh() +end + +local function driver_switched(driver, device) + device_added(driver, device) +end + +--- A device button press prompts a refresh of both SWV2C channels. +local function identify_handler(driver, device, zb_rx) + if not is_dual_channel(device) then return end + device.thread:call_with_delay(2, function() + device:send(OnOff.attributes.OnOff:read(device)) + device:send(OnOff.attributes.OnOff:read(device):to_endpoint(0x02)) + end) end local sonoff_valve_handler = { NAME = "SONOFF Water Valve Handler", + lifecycle_handlers = { + init = device_init, + added = device_added, + doConfigure = do_configure, + driverSwitched = driver_switched, + }, zigbee_handlers = { attr = { - -- OnOff property reporting → valve event (to compensate for the defect of the parent driver's default mapping being skipped) [OnOff.ID] = { [OnOff.attributes.OnOff.ID] = onoff_attr_handler - }, - -- Battery percentage attribute reporting → battery event - [PowerConfiguration.ID] = { - [PowerConfiguration.attributes.BatteryPercentageRemaining.ID] = battery_percentage_handler + } + }, + cluster = { + [Identify.ID] = { + [Identify.server.commands.Identify.ID] = identify_handler } } }, diff --git a/drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve.lua b/drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve.lua index 93fd9ad153..db51025d52 100644 --- a/drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve.lua +++ b/drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve.lua @@ -3,6 +3,7 @@ local test = require "integration_test" local clusters = require "st.zigbee.zcl.clusters" +local Identify = clusters.Identify local OnOff = clusters.OnOff local PowerConfiguration = clusters.PowerConfiguration local capabilities = require "st.capabilities" @@ -98,6 +99,17 @@ test.register_message_test( } ) +test.register_message_test( + "Identify command should not trigger dual-channel reads on SWV1C", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Identify.server.commands.Identify.build_test_rx(mock_device, 1) } + } + } +) + test.register_coroutine_test( "Init lifecycle should use default battery percentage reporting configuration", function () diff --git a/drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve_2.lua b/drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve_2.lua new file mode 100644 index 0000000000..63dd94d209 --- /dev/null +++ b/drivers/SmartThings/zigbee-valve/src/test/test_sonoff_valve_2.lua @@ -0,0 +1,405 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Mock out globals +local test = require "integration_test" +local clusters = require "st.zigbee.zcl.clusters" +local Identify = clusters.Identify +local OnOff = clusters.OnOff +local PowerConfiguration = clusters.PowerConfiguration +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" + +-- Parent device (endpoint 1): valve + battery + firmwareUpdate + refresh +local mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("valve-battery.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "SONOFF", + model = "SWV-ZF2U", + server_clusters = { 0x0000, 0x0001, 0x0006, 0x0404, 0xFC11 } + }, + [2] = { + id = 2, + manufacturer = "SONOFF", + model = "SWV-ZF2U", + server_clusters = { 0x0006 } + } + } + } +) + +-- Child device (endpoint 2): valve + refresh only; no battery +local mock_child = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("valve.yml"), + device_network_id = string.format("%04X:%02X", mock_device:get_short_address(), 2), + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%02X", 2) +}) + +zigbee_test_utils.prepare_zigbee_env_info() +local function test_init() + test.mock_device.add_test_device(mock_device) + test.mock_device.add_test_device(mock_child) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +local function test_init_without_child() + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() +end + +-- ============================================================================ +-- Parent device (endpoint 1) tests: OnOff attribute reports -> valve events +-- ============================================================================ + +test.register_message_test( + "OnOff(on) should set valve to open", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, true) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.valve.valve.open()) + } + } +) + +test.register_message_test( + "OnOff(off) should set valve to closed", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, false) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.valve.valve.closed()) + } + } +) + +-- ============================================================================ +-- Child device (endpoint 2) tests: OnOff attribute reports -> child valve events +-- ============================================================================ + +test.register_message_test( + "OnOff(on) from endpoint 2 should set child valve to open", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_child.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_device, true):from_endpoint(0x02) + } + }, + { + channel = "capability", + direction = "send", + message = mock_child:generate_test_message("main", capabilities.valve.valve.open()) + } + } +) + +test.register_message_test( + "OnOff(off) from endpoint 2 should set child valve to closed", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_child.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_device, false):from_endpoint(0x02) + } + }, + { + channel = "capability", + direction = "send", + message = mock_child:generate_test_message("main", capabilities.valve.valve.closed()) + } + } +) + +test.register_message_test( + "Endpoint 2 report on parent should update child valve", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_device, true):from_endpoint(0x02) + } + }, + { + channel = "capability", + direction = "send", + message = mock_child:generate_test_message("main", capabilities.valve.valve.open()) + } + } +) + +test.register_message_test( + "Endpoint 2 report should not update the parent before child creation", + { + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_device, true):from_endpoint(0x02) + } + } + }, + { test_init = test_init_without_child } +) + +-- ============================================================================ +-- Parent device battery tests +-- ============================================================================ + +-- Battery percentage +test.register_message_test( + "Battery percentage report should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, 55) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(28)) + } + } +) + +test.register_coroutine_test( + "Button Identify command should refresh both valve channels", + function() + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.zigbee:__queue_receive({ + mock_device.id, + Identify.server.commands.Identify.build_test_rx(mock_device, 1) + }) + test.wait_for_events() + + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:read(mock_device):to_endpoint(0x02) + }) + end +) + +-- ============================================================================ +-- Parent device (endpoint 1) valve command tests +-- ============================================================================ + +test.register_message_test( + "Capability(valve) command(open) should send OnOff.On", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "valve", component = "main", command = "open", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.On(mock_device) } + } + } +) + +test.register_message_test( + "Capability(valve) command(close) should send OnOff.Off", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, { capability = "valve", component = "main", command = "close", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.Off(mock_device) } + } + } +) + +-- ============================================================================ +-- Child device (endpoint 2) valve command tests +-- ============================================================================ + +test.register_message_test( + "Child valve command(open) should send OnOff.On to endpoint 2", + { + { + channel = "capability", + direction = "receive", + message = { mock_child.id, { capability = "valve", component = "main", command = "open", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.On(mock_device):to_endpoint(0x02) } + } + } +) + +test.register_message_test( + "Child valve command(close) should send OnOff.Off to endpoint 2", + { + { + channel = "capability", + direction = "receive", + message = { mock_child.id, { capability = "valve", component = "main", command = "close", args = { } } } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.server.commands.Off(mock_device):to_endpoint(0x02) } + } + } +) + +-- ============================================================================ +-- doConfigure lifecycle tests +-- ============================================================================ + +test.register_coroutine_test( + "doConfigure lifecycle should configure device", + function () + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:read(mock_device):to_endpoint(0x02) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, PowerConfiguration.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, 30, 21600, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 600, 0) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID, 2):to_endpoint(0x02) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 600, 0):to_endpoint(0x02) + }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +-- ============================================================================ +-- Refresh tests +-- ============================================================================ + +test.register_message_test( + "Refresh should read all necessary attributes", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.attributes.OnOff:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, OnOff.attributes.OnOff:read(mock_device):to_endpoint(0x02) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) } + } + }, + { + inner_block_ordering = "relaxed" + } +) + +-- ============================================================================ +-- Device added lifecycle tests: child-device creation and refresh +-- ============================================================================ + +test.register_coroutine_test( + "added lifecycle should create child device and refresh parent states", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + -- Verify child-device creation + mock_device:expect_device_create({ + type = "EDGE_CHILD", + label = string.format("%s 2", mock_device.label), + profile = "valve", + parent_device_id = mock_device.id, + parent_assigned_child_key = "02" + }) + + -- Verify that refresh reads parent-device attributes + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:read(mock_device) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + OnOff.attributes.OnOff:read(mock_device):to_endpoint(0x02) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:read(mock_device) + }) + end, + { test_init = test_init_without_child } +) + +test.run_registered_tests() From 97464ebbebbb104639c0847f68ddd1c1d75f2bd3 Mon Sep 17 00:00:00 2001 From: Jiyon Yu Date: Mon, 12 Oct 2026 20:00:17 +0800 Subject: [PATCH 2/6] [WWSTCERT-13857] Add SONOFF TP-WGZBA Zigbee thermostat support (#3241) * Add SONOFF TP-WGZBA support to zigbee-thermostat * Fix TP-WGZBA heating setpoint tests for API 57 compatibility * Fix TP-WGZBA tests without custom capability definitions * Add TP-WGZBA can_handle test coverage * Fix TP-WGZBA firmware version reporting * Fix TP-WGZBA review findings * Address TP-WGZBA review feedback - Encode boolean preferences backed by Uint8 attributes as 0/1 so the Bluetooth pairing broadcast write is actually sent. - Build the temporary-mode payload with string.pack; keep the frame without a manufacturer code, as required by the device firmware. - Rely on base driver and default handlers for temperature, heating setpoint, thermostat mode, running state, driverSwitched and firmware version. - Emit supported thermostat modes in doConfigure. - Shorten preference names to the platform's 24-character limit. * Extend TP-WGZBA temperature calibration range to +/-10 C --- .../zigbee-thermostat/fingerprints.yml | 5 + .../profiles/thermostat-sonoff.yml | 248 ++++++++++++++++++ .../src/sonoff/tp_wgzba/can_handle.lua | 12 + .../src/sonoff/tp_wgzba/fingerprints.lua | 6 + .../src/sonoff/tp_wgzba/init.lua | 206 +++++++++++++++ .../zigbee-thermostat/src/sub_drivers.lua | 1 + .../test/test_sonoff_tp_wgzba_thermostat.lua | 224 ++++++++++++++++ 7 files changed, 702 insertions(+) create mode 100644 drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml create mode 100644 drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/init.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_tp_wgzba_thermostat.lua diff --git a/drivers/SmartThings/zigbee-thermostat/fingerprints.yml b/drivers/SmartThings/zigbee-thermostat/fingerprints.yml index efa89be552..eed404aa9d 100644 --- a/drivers/SmartThings/zigbee-thermostat/fingerprints.yml +++ b/drivers/SmartThings/zigbee-thermostat/fingerprints.yml @@ -139,6 +139,11 @@ zigbeeManufacturer: manufacturer: WALL HERO model: ACL-403STC1 deviceProfileName: thermostat-thirty-buttons-wallhero + - id: "SONOFF/TP-WGZBA" + deviceLabel: SONOFF Thermostat + manufacturer: SONOFF + model: TP-WGZBA + deviceProfileName: thermostat-sonoff zigbeeGeneric: - id: "genericThermostat" deviceLabel: Zigbee Thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml new file mode 100644 index 0000000000..80bd8a5b1d --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml @@ -0,0 +1,248 @@ +name: thermostat-sonoff +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - heating + - idle + - id: thermostatMode + version: 1 + config: + values: + - key: "thermostatMode.value" + enabledValues: + - off + - auto + - heat + - id: thermostatHeatingSetpoint + version: 1 + config: + values: + - key: "heatingSetpoint.value" + range: [5, 30] + step: 0.5 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat +preferences: +- name: temperatureCompensation + title: Temperature compensation + description: Local temperature calibration in degrees Celsius. + required: true + preferenceType: number + definition: + minimum: -10 + maximum: 10 + default: 0 +- name: childLock + title: Child lock + description: Lock the thermostat controls. + required: true + preferenceType: boolean + definition: + default: false +- name: btPairingBroadcast + title: Bluetooth pairing broadcast + description: Enable Bluetooth pairing broadcasts. + required: true + preferenceType: boolean + definition: + default: false +- name: openWindowDetection + title: Open window detection + description: Enable open-window detection. + required: true + preferenceType: boolean + definition: + default: false +- name: frostProofTemperature + title: Frost protection temperature + description: Frost protection setpoint in degrees Celsius. + required: true + preferenceType: number + definition: + minimum: 5 + maximum: 15 + default: 5 +- name: temporaryModeAction + title: Temporary mode + description: Apply an override using the duration and temperature below. + required: true + preferenceType: enumeration + definition: + options: + exit: Exit + boost: Boost + timer: Timer + default: exit +- name: temporaryModeDuration + title: Temporary mode duration + description: Override duration in minutes. + required: true + preferenceType: integer + definition: + minimum: 1 + maximum: 1440 + default: 30 +- name: temporaryModeTemperature + title: Temporary mode temperature + description: Timer override temperature in degrees Celsius. + required: true + preferenceType: number + definition: + minimum: 5 + maximum: 30 + default: 30 +- name: temperatureThresholdLow + title: Temperature threshold low + description: Lower temperature-control threshold in degrees Celsius. + required: true + preferenceType: number + definition: + minimum: -2.6 + maximum: -0.2 + default: -2 +- name: temperatureThresholdHigh + title: Temperature threshold high + description: Upper temperature-control threshold in degrees Celsius. + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 2.6 + default: 2 +- name: radarSensitivity + title: Radar sensitivity + description: Set the radar sensitivity level. + required: true + preferenceType: enumeration + definition: + options: + "0": Low + "1": Medium + "2": High + default: "2" +- name: radarDoNotDisturb + title: Radar do not disturb + description: Enable radar do-not-disturb mode. + required: true + preferenceType: boolean + definition: + default: false +- name: radarDoNotDisturbStart + title: Radar do not disturb start + description: Start time in minutes after midnight rounded to 30 minutes. + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 1410 + default: 0 +- name: radarDoNotDisturbEnd + title: Radar do not disturb end + description: End time in minutes after midnight rounded to 30 minutes. + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 1410 + default: 0 +- name: screenWorkingBrightness + title: Screen working brightness + description: Screen brightness while active. + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 8 + default: 5 +- name: screenStandbyBrightness + title: Screen standby brightness + description: Screen brightness while on standby. + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 8 + default: 0 +- name: nightStandbyBrightness + title: Screen night standby brightness + description: Screen brightness during night standby. + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 8 + default: 0 +- name: screenNightMode + title: Screen night mode + description: Enable screen night mode. + required: true + preferenceType: boolean + definition: + default: false +- name: screenNightStart + title: Screen night mode start + description: Start time in minutes after midnight rounded to 30 minutes. + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 1410 + default: 0 +- name: screenNightEnd + title: Screen night mode end + description: End time in minutes after midnight rounded to 30 minutes. + required: true + preferenceType: integer + definition: + minimum: 0 + maximum: 1410 + default: 0 +- name: heatRelayNormClosed + title: Heating relay normally closed + description: Configure the heating relay contact type. + required: true + preferenceType: boolean + definition: + default: false +- name: boilerRelayNormClosed + title: Boiler relay normally closed + description: Configure the boiler relay contact type. + required: true + preferenceType: boolean + definition: + default: false +- name: overheatProtectTemp + title: Overheat protection temperature + description: Overheat protection setpoint in degrees Celsius. + required: true + preferenceType: number + definition: + minimum: 20 + maximum: 50 + default: 33.5 +- name: overheatProtection + title: Overheat protection + description: Enable overheat protection. + required: true + preferenceType: boolean + definition: + default: false +- name: radarEnabled + title: Radar enabled + description: Enable the radar sensor. + required: true + preferenceType: boolean + definition: + default: false diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/can_handle.lua new file mode 100644 index 0000000000..0a97dad57a --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device, ...) + local fingerprints = require "sonoff.tp_wgzba.fingerprints" + for _, fingerprint in ipairs(fingerprints) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("sonoff.tp_wgzba") + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/fingerprints.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/fingerprints.lua new file mode 100644 index 0000000000..664e907947 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/fingerprints.lua @@ -0,0 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "SONOFF", model = "TP-WGZBA" } +} diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/init.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/init.lua new file mode 100644 index 0000000000..89e21ca91e --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/init.lua @@ -0,0 +1,206 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local cluster_base = require "st.zigbee.cluster_base" +local clusters = require "st.zigbee.zcl.clusters" +local data_types = require "st.zigbee.data_types" +local device_management = require "st.zigbee.device_management" +local generic_body = require "st.zigbee.generic_body" +local utils = require "st.utils" +local ZigbeeConstants = require "st.zigbee.constants" +local ZigbeeMessages = require "st.zigbee.messages" +local ZigbeeZcl = require "st.zigbee.zcl" + +local Thermostat = clusters.Thermostat +local ThermostatHeatingSetpoint = capabilities.thermostatHeatingSetpoint +local ThermostatMode = capabilities.thermostatMode + +local SONOFF_MFG_CODE = 0x1286 +local SONOFF_PRIVATE_CLUSTER = 0xFC11 +local TEMPORARY_MODE_COMMAND_ID = 0x11 + +local PRIVATE_ATTR_CHILD_LOCK = 0x0000 +local PRIVATE_ATTR_BT_PAIRING_BROADCAST = 0x0029 +local PRIVATE_ATTR_OPEN_WINDOW_DETECTION = 0x6000 +local PRIVATE_ATTR_FROST_PROOF_TEMPERATURE = 0x6002 +local PRIVATE_ATTR_TEMPERATURE_CONTROL_THRESHOLD = 0x601F +local PRIVATE_ATTR_RADAR_SENSITIVITY = 0x6020 +local PRIVATE_ATTR_RADAR_DO_NOT_DISTURB = 0x6021 +local PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_PERIOD = 0x6022 +local PRIVATE_ATTR_SCREEN_WORKING_BRIGHTNESS = 0x6023 +local PRIVATE_ATTR_SCREEN_STANDBY_BRIGHTNESS = 0x6024 +local PRIVATE_ATTR_SCREEN_NIGHT_STANDBY_BRIGHTNESS = 0x6025 +local PRIVATE_ATTR_SCREEN_NIGHT_MODE = 0x6026 +local PRIVATE_ATTR_SCREEN_NIGHT_PERIOD = 0x6027 +local PRIVATE_ATTR_RELAY_OUTPUT_TYPE = 0x6028 +local PRIVATE_ATTR_OVERHEAT_PROTECTION_TEMPERATURE = 0x6032 +local PRIVATE_ATTR_OVERHEAT_PROTECTION = 0x6034 +local PRIVATE_ATTR_RADAR_ENABLED = 0x6035 + +local PRIVATE_PREFERENCES = { + childLock = { attribute = PRIVATE_ATTR_CHILD_LOCK, data_type = data_types.Boolean }, + btPairingBroadcast = { attribute = PRIVATE_ATTR_BT_PAIRING_BROADCAST, data_type = data_types.Uint8 }, + openWindowDetection = { attribute = PRIVATE_ATTR_OPEN_WINDOW_DETECTION, data_type = data_types.Boolean }, + frostProofTemperature = { attribute = PRIVATE_ATTR_FROST_PROOF_TEMPERATURE, data_type = data_types.Int16, scale = 100 }, + radarSensitivity = { attribute = PRIVATE_ATTR_RADAR_SENSITIVITY, data_type = data_types.Uint8 }, + radarDoNotDisturb = { attribute = PRIVATE_ATTR_RADAR_DO_NOT_DISTURB, data_type = data_types.Boolean }, + screenWorkingBrightness = { attribute = PRIVATE_ATTR_SCREEN_WORKING_BRIGHTNESS, data_type = data_types.Uint8 }, + screenStandbyBrightness = { attribute = PRIVATE_ATTR_SCREEN_STANDBY_BRIGHTNESS, data_type = data_types.Uint8 }, + nightStandbyBrightness = { attribute = PRIVATE_ATTR_SCREEN_NIGHT_STANDBY_BRIGHTNESS, data_type = data_types.Uint8 }, + screenNightMode = { attribute = PRIVATE_ATTR_SCREEN_NIGHT_MODE, data_type = data_types.Boolean }, + overheatProtectTemp = { attribute = PRIVATE_ATTR_OVERHEAT_PROTECTION_TEMPERATURE, data_type = data_types.Int16, scale = 100 }, + overheatProtection = { attribute = PRIVATE_ATTR_OVERHEAT_PROTECTION, data_type = data_types.Boolean }, + radarEnabled = { attribute = PRIVATE_ATTR_RADAR_ENABLED, data_type = data_types.Boolean }, +} + +-- Builds a manufacturer-specific attribute write for the SONOFF private cluster. +local function write_private_attribute(device, attribute_id, data_type, value) + -- The vendor cluster requires the eWeLink manufacturer code on every write. + return cluster_base.write_manufacturer_specific_attribute(device, SONOFF_PRIVATE_CLUSTER, attribute_id, SONOFF_MFG_CODE, data_type, value) +end + +-- Converts a preference value into the primitive expected by its Zigbee data type. +local function preference_value(value, preference) + -- Boolean preferences are already represented as Lua booleans by the platform. + if preference.data_type == data_types.Boolean then + return value + end + -- Boolean preferences backed by numeric attributes (e.g. Uint8 0x00/0x01) must be encoded as 0/1. + if type(value) == "boolean" then + return value and 1 or 0 + end + local numeric_value = tonumber(value) + if numeric_value == nil then + return nil + end + return preference.scale and utils.round(numeric_value * preference.scale) or numeric_value +end + +-- Restricts a clock value to the device's 30-minute schedule grid. +local function normalize_schedule_minutes(value) + -- The device only accepts values from 00:00 through 23:30 at 30-minute intervals. + local numeric_value = tonumber(value) + if numeric_value == nil then + return nil + end + return utils.clamp_value(math.floor((numeric_value + 15) / 30) * 30, 0, 1410) +end + +-- Writes a paired start/end time preference as a typed Zigbee Structure. +local function write_schedule(device, attribute_id, start_value, end_value) + -- Both schedule attributes are encoded symmetrically as two Uint16 structure members. + local start_minutes = normalize_schedule_minutes(start_value) + local end_minutes = normalize_schedule_minutes(end_value) + if start_minutes == nil or end_minutes == nil then + return + end + device:send(write_private_attribute(device, attribute_id, data_types.Structure, data_types.Structure({ data_types.Uint16(start_minutes), data_types.Uint16(end_minutes) }))) +end + +-- Sends the vendor temporary-mode command using the current preference values. +local function write_temporary_mode(device) + -- Temporary mode is a cluster command rather than an attribute write. + local action_byte = ({ exit = 0, boost = 1, timer = 2 })[device.preferences.temporaryModeAction] + if action_byte == nil then + return + end + local duration_seconds = math.floor(utils.clamp_value(tonumber(device.preferences.temporaryModeDuration) or 30, 1, 1440) * 60) + local temperature = device.preferences.temporaryModeAction == "boost" and 30 or utils.clamp_value(tonumber(device.preferences.temporaryModeTemperature) or 30, 5, 30) + local temperature_centi = utils.round(temperature * 100) + local payload = string.pack("= 40 then + value = utils.f_to_c(value) + end + device:send_to_component(command.component, Thermostat.attributes.OccupiedHeatingSetpoint:write(device, utils.round(utils.clamp_value(value, 5, 30) * 100))) +end + +local sonoff_thermostat = { + NAME = "SONOFF TP-WGZBA Thermostat Handler", + can_handle = require "sonoff.tp_wgzba.can_handle", + lifecycle_handlers = { + added = do_refresh, + doConfigure = do_configure, + infoChanged = info_changed, + }, + capability_handlers = { + [capabilities.refresh.ID] = { [capabilities.refresh.commands.refresh.NAME] = do_refresh }, + [ThermostatHeatingSetpoint.ID] = { [ThermostatHeatingSetpoint.commands.setHeatingSetpoint.NAME] = set_heating_setpoint }, + }, +} + +return sonoff_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua b/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua index 3b535c3cf0..db6c93176c 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua @@ -16,5 +16,6 @@ local sub_drivers = { lazy_load_if_possible("resideo_korea"), lazy_load_if_possible("aqara"), lazy_load_if_possible("wallhero"), + lazy_load_if_possible("sonoff.tp_wgzba"), } return sub_drivers diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_tp_wgzba_thermostat.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_tp_wgzba_thermostat.lua new file mode 100644 index 0000000000..387befbfb4 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_tp_wgzba_thermostat.lua @@ -0,0 +1,224 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local cluster_base = require "st.zigbee.cluster_base" +local clusters = require "st.zigbee.zcl.clusters" +local data_types = require "st.zigbee.data_types" +local generic_body = require "st.zigbee.generic_body" +local t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local messages = require "st.zigbee.messages" +local zcl_messages = require "st.zigbee.zcl" +local zb_const = require "st.zigbee.constants" + +local Basic = clusters.Basic +local Thermostat = clusters.Thermostat +local MFG_CODE = 0x1286 +local PRIVATE_CLUSTER = 0xFC11 + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("thermostat-sonoff.yml"), + preferences = { + temperatureCompensation = 0, + childLock = false, + btPairingBroadcast = false, + openWindowDetection = false, + frostProofTemperature = 5, + temporaryModeAction = "exit", + temporaryModeDuration = 30, + temporaryModeTemperature = 30, + temperatureThresholdLow = -2, + temperatureThresholdHigh = 2, + radarSensitivity = "2", + radarDoNotDisturb = false, + radarDoNotDisturbStart = 0, + radarDoNotDisturbEnd = 0, + screenWorkingBrightness = 5, + screenStandbyBrightness = 0, + nightStandbyBrightness = 0, + screenNightMode = false, + screenNightStart = 0, + screenNightEnd = 0, + heatRelayNormClosed = false, + boilerRelayNormClosed = false, + overheatProtectTemp = 33.5, + overheatProtection = false, + radarEnabled = false, + }, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "SONOFF", + model = "TP-WGZBA", + server_clusters = { Basic.ID, Thermostat.ID, PRIVATE_CLUSTER }, + }, + }, +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + -- Register the device before every test so the SONOFF sub-driver is selected. + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() +end + +test.set_test_init_function(test_init) + +-- Builds the cluster-specific temporary-mode command expected from a preference update. +local function build_temporary_mode_command(payload) + -- The command uses the same manufacturer cluster endpoint as private attribute writes. + local header = zcl_messages.ZclHeader({ cmd = data_types.ZCLCommandId(0x11) }) + header.frame_ctrl:set_cluster_specific() + return messages.ZigbeeMessageTx({ + address_header = messages.AddressHeader( + zb_const.HUB.ADDR, zb_const.HUB.ENDPOINT, mock_device:get_short_address(), + mock_device:get_endpoint(PRIVATE_CLUSTER), zb_const.HA_PROFILE_ID, PRIVATE_CLUSTER + ), + body = zcl_messages.ZclMessageBody({ zcl_header = header, zcl_body = generic_body.GenericBody(payload) }), + }) +end + +test.register_coroutine_test( + "SONOFF sub-driver should match only TP-WGZBA", + function() + -- The flattened handler owns the fingerprint without a nested TP-WGZBA dispatcher. + local can_handle = require "sonoff.tp_wgzba.can_handle" + assert(can_handle({}, nil, mock_device)) + local rejected = can_handle({}, nil, { + get_manufacturer = function() return "SONOFF" end, + get_model = function() return "OTHER" end, + }) + assert(not rejected) + end +) + +test.register_message_test( + "Refresh should read only standard capability attributes", + { + { channel = "capability", direction = "receive", message = { mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } } }, + { channel = "zigbee", direction = "send", message = { mock_device.id, Thermostat.attributes.LocalTemperature:read(mock_device) } }, + { channel = "zigbee", direction = "send", message = { mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device) } }, + { channel = "zigbee", direction = "send", message = { mock_device.id, Thermostat.attributes.SystemMode:read(mock_device) } }, + { channel = "zigbee", direction = "send", message = { mock_device.id, Thermostat.attributes.ThermostatRunningState:read(mock_device) } }, + } +) + +test.register_coroutine_test( + "Configure should bind and enable standard thermostat reporting", + function() + -- All visible state is now maintained by standard reporting instead of custom capabilities. + test.socket.zigbee:__set_channel_ordering("relaxed") + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.supportedThermostatModes({ "off", "auto", "heat" }, { visibility = { displayed = false } }))) + test.socket.zigbee:__expect_send({ mock_device.id, zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Thermostat.ID) }) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.LocalTemperature:configure_reporting(mock_device, 10, 300, 10) }) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:configure_reporting(mock_device, 10, 300, 50) }) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.SystemMode:configure_reporting(mock_device, 10, 300) }) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.ThermostatRunningState:configure_reporting(mock_device, 10, 300, 1) }) + end +) + +test.register_coroutine_test( + "Changing preferences should write typed private attributes and structures", + function() + -- Settings are sent through infoChanged rather than custom capability commands. + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { openWindowDetection = true, temperatureThresholdLow = -1.5, temperatureThresholdHigh = 1.5 }, + })) + test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER, 0x6000, MFG_CODE, data_types.Boolean, true) }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER, 0x601F, MFG_CODE, data_types.Structure, + data_types.Structure({ data_types.Int16(-150), data_types.Int16(150) })) + }) + end +) + +test.register_coroutine_test( + "Bluetooth pairing broadcast preference should write the Uint8 private attribute", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { btPairingBroadcast = true }, + })) + test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER, 0x0029, MFG_CODE, data_types.Uint8, 1) }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { btPairingBroadcast = false }, + })) + test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRIVATE_CLUSTER, 0x0029, MFG_CODE, data_types.Uint8, 0) }) + end +) + +test.register_coroutine_test( + "Temperature compensation preference should write LocalTemperatureCalibration in 0.1 degree units", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { temperatureCompensation = -10 }, + })) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.LocalTemperatureCalibration:write(mock_device, -100) }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { temperatureCompensation = 10 }, + })) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.LocalTemperatureCalibration:write(mock_device, 100) }) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { temperatureCompensation = 2.3 }, + })) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.LocalTemperatureCalibration:write(mock_device, 23) }) + end +) + +test.register_coroutine_test( + "Temporary-mode preference should send the vendor cluster command", + function() + -- Timer mode carries the selected duration and temperature as little-endian values. + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { temporaryModeAction = "timer", temporaryModeDuration = 45, temporaryModeTemperature = 28.5 }, + })) + test.socket.zigbee:__expect_send({ mock_device.id, build_temporary_mode_command("\x02\x8C\x0A\x00\x00\x22\x0B") }) + end +) + +test.register_coroutine_test( + "Setting thermostat mode should use the base driver write and readback", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.thermostatMode.ID, component = "main", command = "auto", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.SystemMode:write(mock_device, Thermostat.attributes.SystemMode.AUTO) }) + test.wait_for_events() + test.mock_time.advance_time(1) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.SystemMode:read(mock_device) }) + end +) + +test.register_message_test( + "Decoded standard reports should publish standard thermostat events", + { + { channel = "zigbee", direction = "receive", message = { mock_device.id, Thermostat.attributes.LocalTemperature:build_test_attr_report(mock_device, 2350) } }, + { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 23.5, unit = "C" })) }, + { channel = "zigbee", direction = "receive", message = { mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:build_test_attr_report(mock_device, 2150) } }, + { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 21.5, unit = "C" })) }, + { channel = "zigbee", direction = "receive", message = { mock_device.id, Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, Thermostat.attributes.SystemMode.AUTO) } }, + { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.auto()) }, + } +) + +-- TP-WGZBA firmware sets ThermostatRunningState to 0x0001 (Heat State On) while the relay is on and 0x0000 otherwise. +test.register_message_test( + "Running state reports should map the heat bit to heating and zero to idle", + { + { channel = "zigbee", direction = "receive", message = { mock_device.id, Thermostat.attributes.ThermostatRunningState:build_test_attr_report(mock_device, 0x0001) } }, + { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState.heating()) }, + { channel = "zigbee", direction = "receive", message = { mock_device.id, Thermostat.attributes.ThermostatRunningState:build_test_attr_report(mock_device, 0x0000) } }, + { channel = "capability", direction = "send", message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState.idle()) }, + } +) + +test.run_registered_tests() From 4897ef385dcbc1d131ac28b2029509783b36d6d9 Mon Sep 17 00:00:00 2001 From: Konrad K <33450498+KKlimczukS@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:21:18 +0200 Subject: [PATCH 3/6] adds a fingerprint for Sonoff SNZB-02UL (#3279) --- drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml b/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml index ee36659670..086c79346a 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml +++ b/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml @@ -93,6 +93,11 @@ zigbeeManufacturer: manufacturer: SONOFF model: SNZB-02B deviceProfileName: humidity-temp-battery + - id: SONOFF/SNZB-02UL + deviceLabel: SONOFF Zigbee Temperature Humidity Sensor + manufacturer: SONOFF + model: SNZB-02UL + deviceProfileName: humidity-temp-battery - id: "Third Reality/3RTHS24BZ" deviceLabel: ThirdReality Temperature and Humidity Sensor manufacturer: Third Reality, Inc From 4e70bf6d0b0a81f0ed7449db9ab39796db3abb29 Mon Sep 17 00:00:00 2001 From: Chris Baumler Date: Fri, 23 Oct 2026 13:21:48 -0500 Subject: [PATCH 4/6] WWSTCERT-13966 YEBRA-Z LED (#3301) * WWSTCERT-13966 YEBRA-Z LED * Re-trigger CLA check Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Sonnet 5.5 --- drivers/SmartThings/matter-switch/fingerprints.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index 935024d61c..43d5b1c72e 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -4351,6 +4351,12 @@ matterManufacturer: vendorId: 0x1664 productId: 0x0300 deviceProfileName: light-color-level +#EGLO Connect.z + - id: "4751/32929" + deviceLabel: YEBRA-Z LED + vendorId: 0x128F + productId: 0x80A1 + deviceProfileName: light-color-level matterGeneric: From d6e3a058533db807ba5d8ac4ffe121d2e502c1fb Mon Sep 17 00:00:00 2001 From: Chris Baumler Date: Wed, 21 Oct 2026 16:35:49 -0500 Subject: [PATCH 5/6] WWSTCERT-11682 Hue Bridge Pro (#2983) * WWSTCERT-11682 Hue Bridge Pro EU * Update device label for Hue Bridge Pro --- drivers/SmartThings/matter-switch/fingerprints.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index 43d5b1c72e..b126382f20 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -4288,6 +4288,11 @@ matterManufacturer: vendorId: 0x100B productId: 0x0002 deviceProfileName: matter-bridge + - id: "4107/3" + deviceLabel: Hue Bridge Pro + vendorId: 0x100B + productId: 0x0003 + deviceProfileName: matter-bridge - id: "Switchbot/hub2" deviceLabel: Matter Bridge vendorId: 0x1397 From 2fd631fdeacb2d99e80acc9c13e0771f0c616814 Mon Sep 17 00:00:00 2001 From: Chris Baumler Date: Tue, 27 Oct 2026 14:01:34 -0500 Subject: [PATCH 6/6] WWSTCERT-14094 Zemismart Matter WiFi IR Thermostat (#3306) --- drivers/SmartThings/matter-thermostat/fingerprints.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/drivers/SmartThings/matter-thermostat/fingerprints.yml b/drivers/SmartThings/matter-thermostat/fingerprints.yml index 399f3861fc..59eefb42b0 100644 --- a/drivers/SmartThings/matter-thermostat/fingerprints.yml +++ b/drivers/SmartThings/matter-thermostat/fingerprints.yml @@ -122,6 +122,12 @@ matterManufacturer: vendorId: 0x1575 productId: 0x0001 deviceProfileName: thermostat-modular + #ZEMISMART + - id: "5020/51713" + deviceLabel: Zemismart Matter WiFi IR Thermostat + vendorId: 0x139C + productId: 0xCA01 + deviceProfileName: thermostat-modular matterGeneric: - id: "matter/hvac/heatcool" deviceLabel: Matter Thermostat