From 08cfbc6e4de8b4901f63bab5d19c64fd165cdd55 Mon Sep 17 00:00:00 2001 From: quannan <15505180750@163.com> Date: Sat, 29 Aug 2026 17:14:01 +0800 Subject: [PATCH 01/10] feat(zigbee): add SONOFF TRV-ZBT thermostat --- .../zigbee-thermostat/fingerprints.yml | 5 + .../profiles/thermostat-sonoff-trv-zbt.yml | 27 ++ .../src/sonoff/can_handle.lua | 12 + .../zigbee-thermostat/src/sonoff/init.lua | 240 ++++++++++++++ .../zigbee-thermostat/src/sub_drivers.lua | 1 + .../src/test/test_sonoff_trv_zbt.lua | 305 ++++++++++++++++++ 6 files changed, 590 insertions(+) create mode 100644 drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff-trv-zbt.yml create mode 100644 drivers/SmartThings/zigbee-thermostat/src/sonoff/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua diff --git a/drivers/SmartThings/zigbee-thermostat/fingerprints.yml b/drivers/SmartThings/zigbee-thermostat/fingerprints.yml index efa89be552..ed6afd59b5 100644 --- a/drivers/SmartThings/zigbee-thermostat/fingerprints.yml +++ b/drivers/SmartThings/zigbee-thermostat/fingerprints.yml @@ -1,4 +1,9 @@ zigbeeManufacturer: + - id: "SONOFF/TRV-ZBT" + deviceLabel: SONOFF TRV-ZBT + manufacturer: SONOFF + model: TRV-ZBT + deviceProfileName: thermostat-sonoff-trv-zbt - id: "Vimar/02973-smart-wheel-thermostat" deviceLabel: Vimar Smart Thermostat manufacturer: Vimar diff --git a/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff-trv-zbt.yml b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff-trv-zbt.yml new file mode 100644 index 0000000000..32f11541c5 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff-trv-zbt.yml @@ -0,0 +1,27 @@ +name: thermostat-sonoff-trv-zbt +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + config: + values: + - key: "heatingSetpoint.value" + range: [5, 30] + - id: thermostatMode + version: 1 + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat +- id: bluetooth + label: Bluetooth Pairing + capabilities: + - id: momentary + version: 1 + categories: + - name: Button diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/can_handle.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/can_handle.lua new file mode 100644 index 0000000000..9b270451e8 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/can_handle.lua @@ -0,0 +1,12 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle(opts, driver, device) + if device:get_manufacturer() == "SONOFF" and device:get_model() == "TRV-ZBT" then + return true, require("sonoff") + end + + return false +end + +return can_handle diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua new file mode 100644 index 0000000000..61270b4231 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua @@ -0,0 +1,240 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +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 messages = require "st.zigbee.messages" +local zcl_messages = require "st.zigbee.zcl" +local zb_const = require "st.zigbee.constants" +local utils = require "st.utils" + +local PowerConfiguration = clusters.PowerConfiguration +local Thermostat = clusters.Thermostat + +local Battery = capabilities.battery +local Momentary = capabilities.momentary +local Refresh = capabilities.refresh +local TemperatureMeasurement = capabilities.temperatureMeasurement +local ThermostatHeatingSetpoint = capabilities.thermostatHeatingSetpoint +local ThermostatMode = capabilities.thermostatMode + +local SONOFF_CLUSTER = 0xFC11 +local WORK_MODE_ATTR = 0x0018 +local BUTTON_COMMAND = 0x10 +local BLUETOOTH_PAIRING_START = 0x01 + +local WORK_MODE_OFF = 0x01 +local WORK_MODE_MANUAL = 0x03 +local WORK_MODE_SCHEDULE = 0x04 +local WORK_MODE_TEMP_MANUAL = 0x05 + +local MIN_SETPOINT = 5 +local MAX_SETPOINT = 30 +local SUPPORTED_MODES = { + ThermostatMode.thermostatMode.off.NAME, + ThermostatMode.thermostatMode.heat.NAME, + ThermostatMode.thermostatMode.auto.NAME, +} + +local function emit_setpoint_range(device) + device:emit_event(ThermostatHeatingSetpoint.heatingSetpointRange({ + value = { minimum = MIN_SETPOINT, maximum = MAX_SETPOINT }, + unit = "C", + }, { visibility = { displayed = false } })) +end + +local function emit_supported_modes(device) + device:emit_event(ThermostatMode.supportedThermostatModes(SUPPORTED_MODES, { + visibility = { displayed = false }, + })) +end + +local function emit_mode(device, mode) + device:emit_event(ThermostatMode.thermostatMode[mode]()) +end + +local function endpoint_for(device, cluster) + return device:get_endpoint(cluster) or 1 +end + +local function send_bluetooth_pairing_start(device) + local zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(BUTTON_COMMAND), + }) + zcl_header.frame_ctrl:set_cluster_specific() + + device:send(messages.ZigbeeMessageTx({ + address_header = messages.AddressHeader( + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + device:get_short_address(), + endpoint_for(device, SONOFF_CLUSTER), + zb_const.HA_PROFILE_ID, + SONOFF_CLUSTER + ), + body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = generic_body.GenericBody(string.char(0x03, 0x01, BLUETOOTH_PAIRING_START)), + }), + })) +end + +local function refresh(_, device) + local attributes = { + Thermostat.attributes.LocalTemperature, + Thermostat.attributes.OccupiedHeatingSetpoint, + Thermostat.attributes.SystemMode, + Thermostat.attributes.MinHeatSetpointLimit, + Thermostat.attributes.MaxHeatSetpointLimit, + PowerConfiguration.attributes.BatteryPercentageRemaining, + } + + for _, attribute in ipairs(attributes) do + device:send(attribute:read(device)) + end +end + +local function configure(driver, device) + local hub_eui = driver.environment_info.hub_zigbee_eui + device:send(device_management.build_bind_request(device, Thermostat.ID, hub_eui)) + device:send(device_management.build_bind_request(device, PowerConfiguration.ID, hub_eui)) + device:send(device_management.build_bind_request(device, SONOFF_CLUSTER, hub_eui)) + device:send(Thermostat.attributes.LocalTemperature:configure_reporting(device, 10, 300, 50)) + device:send(Thermostat.attributes.OccupiedHeatingSetpoint:configure_reporting(device, 1, 600, 50)) + device:send(Thermostat.attributes.SystemMode:configure_reporting(device, 1, 600, 1)) + device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 30, 21600, 5)) + refresh(driver, device) +end + +local function added(_, device) + emit_supported_modes(device) + emit_setpoint_range(device) +end + +local function init(driver, device) + added(driver, device) + refresh(driver, device) +end + +local function local_temperature_handler(_, device, value) + if value.value ~= 0x8000 and value.value ~= -32768 then + device:emit_event(TemperatureMeasurement.temperature({ value = value.value / 100, unit = "C" })) + end +end + +local function heating_setpoint_handler(_, device, value) + if value.value ~= 0x8000 and value.value ~= -32768 then + device:emit_event(ThermostatHeatingSetpoint.heatingSetpoint({ value = value.value / 100, unit = "C" })) + end +end + +local function system_mode_handler(_, device, value) + if value.value == Thermostat.attributes.SystemMode.OFF then + emit_mode(device, ThermostatMode.thermostatMode.off.NAME) + elseif value.value == Thermostat.attributes.SystemMode.AUTO then + emit_mode(device, ThermostatMode.thermostatMode.auto.NAME) + elseif value.value == Thermostat.attributes.SystemMode.HEAT then + emit_mode(device, ThermostatMode.thermostatMode.heat.NAME) + end +end + +local function battery_handler(_, device, value) + device:emit_event(Battery.battery(utils.clamp_value(utils.round(value.value / 2), 0, 100))) +end + +local function work_mode_handler(_, device, value) + if value.value == WORK_MODE_OFF then + emit_mode(device, ThermostatMode.thermostatMode.off.NAME) + elseif value.value == WORK_MODE_SCHEDULE then + emit_mode(device, ThermostatMode.thermostatMode.auto.NAME) + elseif value.value == WORK_MODE_MANUAL or value.value == WORK_MODE_TEMP_MANUAL then + emit_mode(device, ThermostatMode.thermostatMode.heat.NAME) + end +end + +local function set_heating_setpoint(_, device, command) + local setpoint = command.args.setpoint + if setpoint >= 40 then + setpoint = utils.f_to_c(setpoint) + end + + setpoint = utils.clamp_value(setpoint, MIN_SETPOINT, MAX_SETPOINT) + device:send(Thermostat.attributes.OccupiedHeatingSetpoint:write(device, utils.round(setpoint * 100))) + device:emit_event(ThermostatHeatingSetpoint.heatingSetpoint({ value = setpoint, unit = "C" })) + device.thread:call_with_delay(1, function() + device:send(Thermostat.attributes.OccupiedHeatingSetpoint:read(device)) + end) +end + +local function set_mode(_, device, command) + local mode_to_system_mode = { + [ThermostatMode.thermostatMode.off.NAME] = Thermostat.attributes.SystemMode.OFF, + [ThermostatMode.thermostatMode.heat.NAME] = Thermostat.attributes.SystemMode.HEAT, + [ThermostatMode.thermostatMode.auto.NAME] = Thermostat.attributes.SystemMode.AUTO, + } + local system_mode = mode_to_system_mode[command.args.mode] + if system_mode == nil then + return + end + + device:send(Thermostat.attributes.SystemMode:write(device, system_mode)) + emit_mode(device, command.args.mode) + device.thread:call_with_delay(1, function() + device:send(Thermostat.attributes.SystemMode:read(device)) + end) +end + +local function mode_setter(mode) + return function(driver, device, command) + set_mode(driver, device, { args = { mode = mode } }) + end +end + +local sonoff = { + NAME = "SONOFF TRV-ZBT", + capability_handlers = { + [Refresh.ID] = { + [Refresh.commands.refresh.NAME] = refresh, + }, + [Momentary.ID] = { + [Momentary.commands.push.NAME] = function(_, device) + send_bluetooth_pairing_start(device) + end, + }, + [ThermostatHeatingSetpoint.ID] = { + [ThermostatHeatingSetpoint.commands.setHeatingSetpoint.NAME] = set_heating_setpoint, + }, + [ThermostatMode.ID] = { + [ThermostatMode.commands.setThermostatMode.NAME] = set_mode, + [ThermostatMode.commands.off.NAME] = mode_setter(ThermostatMode.thermostatMode.off.NAME), + [ThermostatMode.commands.heat.NAME] = mode_setter(ThermostatMode.thermostatMode.heat.NAME), + [ThermostatMode.commands.auto.NAME] = mode_setter(ThermostatMode.thermostatMode.auto.NAME), + }, + }, + zigbee_handlers = { + attr = { + [PowerConfiguration.ID] = { + [PowerConfiguration.attributes.BatteryPercentageRemaining.ID] = battery_handler, + }, + [Thermostat.ID] = { + [Thermostat.attributes.LocalTemperature.ID] = local_temperature_handler, + [Thermostat.attributes.OccupiedHeatingSetpoint.ID] = heating_setpoint_handler, + [Thermostat.attributes.SystemMode.ID] = system_mode_handler, + }, + [SONOFF_CLUSTER] = { + [WORK_MODE_ATTR] = work_mode_handler, + }, + }, + }, + lifecycle_handlers = { + added = added, + init = init, + doConfigure = configure, + }, + can_handle = require "sonoff.can_handle", +} + +return sonoff diff --git a/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua b/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua index 3b535c3cf0..12bccc65a7 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua @@ -3,6 +3,7 @@ local lazy_load_if_possible = require "lazy_load_subdriver" local sub_drivers = { + lazy_load_if_possible("sonoff"), lazy_load_if_possible("zenwithin"), lazy_load_if_possible("fidure"), lazy_load_if_possible("sinope"), diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua new file mode 100644 index 0000000000..bfcfde6b33 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua @@ -0,0 +1,305 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +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 messages = require "st.zigbee.messages" +local zcl_messages = require "st.zigbee.zcl" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local t_utils = require "integration_test.utils" +local zb_const = require "st.zigbee.constants" + +local PowerConfiguration = clusters.PowerConfiguration +local Thermostat = clusters.Thermostat + +local SONOFF_CLUSTER = 0xFC11 +local WORK_MODE_ATTR = 0x0018 +local BUTTON_COMMAND = 0x10 + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("thermostat-sonoff-trv-zbt.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "SONOFF", + model = "TRV-ZBT", + server_clusters = { 0x0001, 0x0201, 0x0402, SONOFF_CLUSTER }, + }, + }, +}) + +zigbee_test_utils.prepare_zigbee_env_info() +test.set_test_init_function(function() + test.mock_device.add_test_device(mock_device) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatMode.supportedThermostatModes( + { "off", "heat", "auto" }, { visibility = { displayed = false } } + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ + value = { minimum = 5, maximum = 30 }, + unit = "C", + }, { visibility = { displayed = false } })) + ) + for _, attribute in ipairs({ + Thermostat.attributes.LocalTemperature, + Thermostat.attributes.OccupiedHeatingSetpoint, + Thermostat.attributes.SystemMode, + Thermostat.attributes.MinHeatSetpointLimit, + Thermostat.attributes.MaxHeatSetpointLimit, + PowerConfiguration.attributes.BatteryPercentageRemaining, + }) do + test.socket.zigbee:__expect_send({ mock_device.id, attribute:read(mock_device) }) + end +end) + +local function bluetooth_pairing_command(device) + local zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(BUTTON_COMMAND), + }) + zcl_header.frame_ctrl:set_cluster_specific() + + return messages.ZigbeeMessageTx({ + address_header = messages.AddressHeader( + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + device:get_short_address(), + 1, + zb_const.HA_PROFILE_ID, + SONOFF_CLUSTER + ), + body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = generic_body.GenericBody(string.char(0x03, 0x01, 0x01)), + }), + }) +end + +test.register_message_test( + "SONOFF TRV-ZBT reports local temperature", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.LocalTemperature:build_test_attr_report(mock_device, 2150) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 21.5, unit = "C" })), + }, + }, + { min_api_version = 14 } +) + +test.register_message_test( + "SONOFF TRV-ZBT reports setpoint and battery percentage", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:build_test_attr_report(mock_device, 2250) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 22.5, unit = "C" })), + }, + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:build_test_attr_report(mock_device, 146) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(73)), + }, + }, + { min_api_version = 14 } +) + +test.register_message_test( + "SONOFF TRV-ZBT maps standard and proprietary modes", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.SystemMode:build_test_attr_report(mock_device, Thermostat.attributes.SystemMode.HEAT) }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.heat()), + }, + { + channel = "zigbee", + direction = "receive", + message = { + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, SONOFF_CLUSTER, { + { WORK_MODE_ATTR, data_types.Uint8.ID, 0x04 }, + }), + }, + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.auto()), + }, + }, + { min_api_version = 14 } +) + +test.register_coroutine_test( + "SONOFF TRV-ZBT writes setpoints in Celsius and Fahrenheit", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = {}, + named_args = { setpoint = 21.5 }, + }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 2150), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 21.5, unit = "C" })) + ) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = {}, + named_args = { setpoint = 68 }, + }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 2000), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 20.0, unit = "C" })) + ) + test.wait_for_events() + end, + { min_api_version = 14 } +) + +test.register_coroutine_test( + "SONOFF TRV-ZBT starts Bluetooth pairing from the momentary component", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "momentary", component = "bluetooth", command = "push", args = {} }, + }) + test.socket.zigbee:__expect_send({ mock_device.id, bluetooth_pairing_command(mock_device) }) + test.wait_for_events() + end, + { min_api_version = 14 } +) + +test.register_coroutine_test( + "SONOFF TRV-ZBT writes thermostat mode and refreshes required attributes", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = {}, + named_args = { mode = "auto" }, + }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.SystemMode:write(mock_device, Thermostat.attributes.SystemMode.AUTO), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode.auto()) + ) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} }, + }) + for _, attribute in ipairs({ + Thermostat.attributes.LocalTemperature, + Thermostat.attributes.OccupiedHeatingSetpoint, + Thermostat.attributes.SystemMode, + Thermostat.attributes.MinHeatSetpointLimit, + Thermostat.attributes.MaxHeatSetpointLimit, + PowerConfiguration.attributes.BatteryPercentageRemaining, + }) do + test.socket.zigbee:__expect_send({ mock_device.id, attribute:read(mock_device) }) + end + test.wait_for_events() + end, + { min_api_version = 14 } +) + +test.register_coroutine_test( + "SONOFF TRV-ZBT configures reporting and cluster bindings", + function() + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + device_management.build_bind_request(mock_device, Thermostat.ID, zigbee_test_utils.mock_hub_eui), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + device_management.build_bind_request(mock_device, PowerConfiguration.ID, zigbee_test_utils.mock_hub_eui), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + device_management.build_bind_request(mock_device, SONOFF_CLUSTER, zigbee_test_utils.mock_hub_eui), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.LocalTemperature:configure_reporting(mock_device, 10, 300, 50), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:configure_reporting(mock_device, 1, 600, 50), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.SystemMode:configure_reporting(mock_device, 1, 600, 1), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, 30, 21600, 5), + }) + for _, attribute in ipairs({ + Thermostat.attributes.LocalTemperature, + Thermostat.attributes.OccupiedHeatingSetpoint, + Thermostat.attributes.SystemMode, + Thermostat.attributes.MinHeatSetpointLimit, + Thermostat.attributes.MaxHeatSetpointLimit, + PowerConfiguration.attributes.BatteryPercentageRemaining, + }) do + test.socket.zigbee:__expect_send({ mock_device.id, attribute:read(mock_device) }) + end + test.wait_for_events() + end, + { min_api_version = 14 } +) + +test.run_registered_tests() From 5fdbbdacf77db16f4c6d76fed4e583249714332c Mon Sep 17 00:00:00 2001 From: quannan <15505180750@163.com> Date: Sat, 29 Aug 2026 18:54:32 +0800 Subject: [PATCH 02/10] fix(zigbee): support legacy thermostat commands --- .../zigbee-thermostat/src/sonoff/init.lua | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua index 61270b4231..d013da7928 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua @@ -60,6 +60,14 @@ local function endpoint_for(device, cluster) return device:get_endpoint(cluster) or 1 end +local function command_argument(command, name) + if command.args ~= nil and command.args[name] ~= nil then + return command.args[name] + end + + return command.named_args and command.named_args[name] +end + local function send_bluetooth_pairing_start(device) local zcl_header = zcl_messages.ZclHeader({ cmd = data_types.ZCLCommandId(BUTTON_COMMAND), @@ -156,7 +164,7 @@ local function work_mode_handler(_, device, value) end local function set_heating_setpoint(_, device, command) - local setpoint = command.args.setpoint + local setpoint = command_argument(command, "setpoint") if setpoint >= 40 then setpoint = utils.f_to_c(setpoint) end @@ -175,13 +183,14 @@ local function set_mode(_, device, command) [ThermostatMode.thermostatMode.heat.NAME] = Thermostat.attributes.SystemMode.HEAT, [ThermostatMode.thermostatMode.auto.NAME] = Thermostat.attributes.SystemMode.AUTO, } - local system_mode = mode_to_system_mode[command.args.mode] + local mode = command_argument(command, "mode") + local system_mode = mode_to_system_mode[mode] if system_mode == nil then return end device:send(Thermostat.attributes.SystemMode:write(device, system_mode)) - emit_mode(device, command.args.mode) + emit_mode(device, mode) device.thread:call_with_delay(1, function() device:send(Thermostat.attributes.SystemMode:read(device)) end) From b2fd4243ebc594811c2f3f62ae8df45f16756665 Mon Sep 17 00:00:00 2001 From: quannan <15505180750@163.com> Date: Tue, 15 Sep 2026 15:10:32 +0800 Subject: [PATCH 03/10] fix(zigbee): align SONOFF setpoint precision --- drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua index d013da7928..5589983789 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua @@ -33,6 +33,7 @@ local WORK_MODE_TEMP_MANUAL = 0x05 local MIN_SETPOINT = 5 local MAX_SETPOINT = 30 +local SETPOINT_STEP = 0.5 local SUPPORTED_MODES = { ThermostatMode.thermostatMode.off.NAME, ThermostatMode.thermostatMode.heat.NAME, @@ -41,7 +42,7 @@ local SUPPORTED_MODES = { local function emit_setpoint_range(device) device:emit_event(ThermostatHeatingSetpoint.heatingSetpointRange({ - value = { minimum = MIN_SETPOINT, maximum = MAX_SETPOINT }, + value = { minimum = MIN_SETPOINT, maximum = MAX_SETPOINT, step = SETPOINT_STEP }, unit = "C", }, { visibility = { displayed = false } })) end @@ -170,6 +171,8 @@ local function set_heating_setpoint(_, device, command) end setpoint = utils.clamp_value(setpoint, MIN_SETPOINT, MAX_SETPOINT) + -- The TRV-ZBT persists heating setpoints in 0.5 C increments. + setpoint = math.floor((setpoint / SETPOINT_STEP) + 0.000001) * SETPOINT_STEP device:send(Thermostat.attributes.OccupiedHeatingSetpoint:write(device, utils.round(setpoint * 100))) device:emit_event(ThermostatHeatingSetpoint.heatingSetpoint({ value = setpoint, unit = "C" })) device.thread:call_with_delay(1, function() From 4d657e240da4b4406af5ed084a6751d3e94e5ba7 Mon Sep 17 00:00:00 2001 From: quannan <15505180750@163.com> Date: Tue, 15 Sep 2026 15:17:23 +0800 Subject: [PATCH 04/10] test(zigbee): cover SONOFF setpoint precision --- .../src/test/test_sonoff_trv_zbt.lua | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua index bfcfde6b33..dd11914390 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua @@ -42,7 +42,7 @@ test.set_test_init_function(function() ) test.socket.capability:__expect_send( mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ - value = { minimum = 5, maximum = 30 }, + value = { minimum = 5, maximum = 30, step = 0.5 }, unit = "C", }, { visibility = { displayed = false } })) ) @@ -196,6 +196,25 @@ test.register_coroutine_test( mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 20.0, unit = "C" })) ) test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = {}, + named_args = { setpoint = 64 }, + }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 1750), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 17.5, unit = "C" })) + ) + test.wait_for_events() end, { min_api_version = 14 } ) From 920832355f59ca135705e1317c195936803d186f Mon Sep 17 00:00:00 2001 From: quannan <96817118+QuAnnan2019@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:57:30 +0800 Subject: [PATCH 05/10] fix(zigbee): address SONOFF TRV-ZBT review feedback --- .../profiles/thermostat-sonoff-trv-zbt.yml | 11 +++ .../zigbee-thermostat/src/sonoff/init.lua | 78 +++++++++++++++---- .../src/test/test_sonoff_trv_zbt.lua | 44 ++++++++++- 3 files changed, 117 insertions(+), 16 deletions(-) diff --git a/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff-trv-zbt.yml b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff-trv-zbt.yml index 32f11541c5..2ca91f3fdd 100644 --- a/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff-trv-zbt.yml +++ b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff-trv-zbt.yml @@ -25,3 +25,14 @@ components: version: 1 categories: - name: Button +preferences: +- name: "childLock" + title: "Child lock" + description: "Enable or disable the child lock of the thermostat." + required: false + preferenceType: enumeration + definition: + options: + 0: "Unlock" + 1: "Lock" + default: 0 diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua index 5589983789..e102e5841f 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua @@ -3,8 +3,8 @@ local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" 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 messages = require "st.zigbee.messages" local zcl_messages = require "st.zigbee.zcl" @@ -22,6 +22,7 @@ local ThermostatHeatingSetpoint = capabilities.thermostatHeatingSetpoint local ThermostatMode = capabilities.thermostatMode local SONOFF_CLUSTER = 0xFC11 +local CHILD_LOCK_ATTR = 0x0000 local WORK_MODE_ATTR = 0x0018 local BUTTON_COMMAND = 0x10 local BLUETOOTH_PAIRING_START = 0x01 @@ -34,15 +35,54 @@ local WORK_MODE_TEMP_MANUAL = 0x05 local MIN_SETPOINT = 5 local MAX_SETPOINT = 30 local SETPOINT_STEP = 0.5 +local CHILD_LOCK_PREFERENCE = "childLock" local SUPPORTED_MODES = { ThermostatMode.thermostatMode.off.NAME, ThermostatMode.thermostatMode.heat.NAME, ThermostatMode.thermostatMode.auto.NAME, } +local CONFIGURATIONS = { + { + cluster = Thermostat.ID, + attribute = Thermostat.attributes.LocalTemperature.ID, + minimum_interval = 10, + maximum_interval = 300, + data_type = Thermostat.attributes.LocalTemperature.base_type, + reportable_change = 50, + }, + { + cluster = Thermostat.ID, + attribute = Thermostat.attributes.OccupiedHeatingSetpoint.ID, + minimum_interval = 1, + maximum_interval = 600, + data_type = Thermostat.attributes.OccupiedHeatingSetpoint.base_type, + reportable_change = 50, + }, + { + cluster = Thermostat.ID, + attribute = Thermostat.attributes.SystemMode.ID, + minimum_interval = 1, + maximum_interval = 600, + data_type = Thermostat.attributes.SystemMode.base_type, + }, + { + cluster = PowerConfiguration.ID, + attribute = PowerConfiguration.attributes.BatteryPercentageRemaining.ID, + minimum_interval = 30, + maximum_interval = 21600, + data_type = PowerConfiguration.attributes.BatteryPercentageRemaining.base_type, + reportable_change = 5, + }, +} + local function emit_setpoint_range(device) device:emit_event(ThermostatHeatingSetpoint.heatingSetpointRange({ - value = { minimum = MIN_SETPOINT, maximum = MAX_SETPOINT, step = SETPOINT_STEP }, + value = { + minimum = MIN_SETPOINT, + maximum = MAX_SETPOINT, + step = SETPOINT_STEP, + }, unit = "C", }, { visibility = { displayed = false } })) end @@ -57,10 +97,6 @@ local function emit_mode(device, mode) device:emit_event(ThermostatMode.thermostatMode[mode]()) end -local function endpoint_for(device, cluster) - return device:get_endpoint(cluster) or 1 -end - local function command_argument(command, name) if command.args ~= nil and command.args[name] ~= nil then return command.args[name] @@ -80,7 +116,7 @@ local function send_bluetooth_pairing_start(device) zb_const.HUB.ADDR, zb_const.HUB.ENDPOINT, device:get_short_address(), - endpoint_for(device, SONOFF_CLUSTER), + device:get_endpoint(SONOFF_CLUSTER) or 1, zb_const.HA_PROFILE_ID, SONOFF_CLUSTER ), @@ -107,14 +143,7 @@ local function refresh(_, device) end local function configure(driver, device) - local hub_eui = driver.environment_info.hub_zigbee_eui - device:send(device_management.build_bind_request(device, Thermostat.ID, hub_eui)) - device:send(device_management.build_bind_request(device, PowerConfiguration.ID, hub_eui)) - device:send(device_management.build_bind_request(device, SONOFF_CLUSTER, hub_eui)) - device:send(Thermostat.attributes.LocalTemperature:configure_reporting(device, 10, 300, 50)) - device:send(Thermostat.attributes.OccupiedHeatingSetpoint:configure_reporting(device, 1, 600, 50)) - device:send(Thermostat.attributes.SystemMode:configure_reporting(device, 1, 600, 1)) - device:send(PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(device, 30, 21600, 5)) + device:configure() refresh(driver, device) end @@ -124,10 +153,28 @@ local function added(_, device) end local function init(driver, device) + for _, configuration in ipairs(CONFIGURATIONS) do + device:add_configured_attribute(configuration) + end added(driver, device) refresh(driver, device) end +local function info_changed(_, device, _, args) + local old_preferences = args.old_st_store and args.old_st_store.preferences or {} + local child_lock = device.preferences[CHILD_LOCK_PREFERENCE] + if child_lock == nil or old_preferences[CHILD_LOCK_PREFERENCE] == child_lock then + return + end + + device:send(cluster_base.write_attribute( + device, + data_types.ClusterId(SONOFF_CLUSTER), + data_types.AttributeId(CHILD_LOCK_ATTR), + data_types.validate_or_build_type(tonumber(child_lock) == 1, data_types.Boolean, "payload") + )) +end + local function local_temperature_handler(_, device, value) if value.value ~= 0x8000 and value.value ~= -32768 then device:emit_event(TemperatureMeasurement.temperature({ value = value.value / 100, unit = "C" })) @@ -245,6 +292,7 @@ local sonoff = { added = added, init = init, doConfigure = configure, + infoChanged = info_changed, }, can_handle = require "sonoff.can_handle", } diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua index dd11914390..05f5f92d49 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua @@ -4,6 +4,7 @@ local test = require "integration_test" local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" local data_types = require "st.zigbee.data_types" local device_management = require "st.zigbee.device_management" local generic_body = require "st.zigbee.generic_body" @@ -15,8 +16,10 @@ local zb_const = require "st.zigbee.constants" local PowerConfiguration = clusters.PowerConfiguration local Thermostat = clusters.Thermostat +local TemperatureMeasurement = clusters.TemperatureMeasurement local SONOFF_CLUSTER = 0xFC11 +local CHILD_LOCK_ATTR = 0x0000 local WORK_MODE_ATTR = 0x0018 local BUTTON_COMMAND = 0x10 @@ -232,6 +235,40 @@ test.register_coroutine_test( { min_api_version = 14 } ) +test.register_coroutine_test( + "SONOFF TRV-ZBT writes the child lock preference", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { childLock = 1 }, + })) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_attribute( + mock_device, + data_types.ClusterId(SONOFF_CLUSTER), + data_types.AttributeId(CHILD_LOCK_ATTR), + data_types.validate_or_build_type(true, data_types.Boolean, "payload") + ), + }) + test.wait_for_events() + + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { childLock = 0 }, + })) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_attribute( + mock_device, + data_types.ClusterId(SONOFF_CLUSTER), + data_types.AttributeId(CHILD_LOCK_ATTR), + data_types.validate_or_build_type(false, data_types.Boolean, "payload") + ), + }) + test.wait_for_events() + end, + { min_api_version = 14 } +) + test.register_coroutine_test( "SONOFF TRV-ZBT writes thermostat mode and refreshes required attributes", function() @@ -277,6 +314,7 @@ test.register_coroutine_test( "SONOFF TRV-ZBT configures reporting and cluster bindings", function() mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + 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, @@ -288,7 +326,7 @@ test.register_coroutine_test( }) test.socket.zigbee:__expect_send({ mock_device.id, - device_management.build_bind_request(mock_device, SONOFF_CLUSTER, zigbee_test_utils.mock_hub_eui), + device_management.build_bind_request(mock_device, TemperatureMeasurement.ID, zigbee_test_utils.mock_hub_eui), }) test.socket.zigbee:__expect_send({ mock_device.id, @@ -306,6 +344,10 @@ test.register_coroutine_test( mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, 30, 21600, 5), }) + test.socket.zigbee:__expect_send({ + mock_device.id, + TemperatureMeasurement.attributes.MeasuredValue:configure_reporting(mock_device, 30, 600, 100), + }) for _, attribute in ipairs({ Thermostat.attributes.LocalTemperature, Thermostat.attributes.OccupiedHeatingSetpoint, From 766f4e14f1915f74b058c2118281f5052cd9c36c Mon Sep 17 00:00:00 2001 From: quannan <15505180750@163.com> Date: Tue, 29 Sep 2026 09:19:30 +0800 Subject: [PATCH 06/10] fix(zigbee): address SONOFF pairing review feedback --- .../profiles/thermostat-sonoff-trv-zbt.yml | 14 ++-- .../zigbee-thermostat/src/sonoff/init.lua | 71 ++++++++----------- .../src/test/test_sonoff_trv_zbt.lua | 14 ++-- 3 files changed, 45 insertions(+), 54 deletions(-) diff --git a/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff-trv-zbt.yml b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff-trv-zbt.yml index 2ca91f3fdd..16ad1353b6 100644 --- a/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff-trv-zbt.yml +++ b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff-trv-zbt.yml @@ -18,13 +18,6 @@ components: version: 1 categories: - name: Thermostat -- id: bluetooth - label: Bluetooth Pairing - capabilities: - - id: momentary - version: 1 - categories: - - name: Button preferences: - name: "childLock" title: "Child lock" @@ -36,3 +29,10 @@ preferences: 0: "Unlock" 1: "Lock" default: 0 +- name: "enterBluetoothPairing" + title: "Enter Bluetooth pairing" + description: "Enable to put the thermostat into Bluetooth pairing mode. The pairing window is controlled by the thermostat firmware." + required: false + preferenceType: boolean + definition: + default: false diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua index e102e5841f..da4ba8225e 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua @@ -15,7 +15,6 @@ local PowerConfiguration = clusters.PowerConfiguration local Thermostat = clusters.Thermostat local Battery = capabilities.battery -local Momentary = capabilities.momentary local Refresh = capabilities.refresh local TemperatureMeasurement = capabilities.temperatureMeasurement local ThermostatHeatingSetpoint = capabilities.thermostatHeatingSetpoint @@ -24,9 +23,6 @@ local ThermostatMode = capabilities.thermostatMode local SONOFF_CLUSTER = 0xFC11 local CHILD_LOCK_ATTR = 0x0000 local WORK_MODE_ATTR = 0x0018 -local BUTTON_COMMAND = 0x10 -local BLUETOOTH_PAIRING_START = 0x01 - local WORK_MODE_OFF = 0x01 local WORK_MODE_MANUAL = 0x03 local WORK_MODE_SCHEDULE = 0x04 @@ -36,6 +32,7 @@ local MIN_SETPOINT = 5 local MAX_SETPOINT = 30 local SETPOINT_STEP = 0.5 local CHILD_LOCK_PREFERENCE = "childLock" +local BLUETOOTH_PAIRING_PREFERENCE = "enterBluetoothPairing" local SUPPORTED_MODES = { ThermostatMode.thermostatMode.off.NAME, ThermostatMode.thermostatMode.heat.NAME, @@ -105,28 +102,6 @@ local function command_argument(command, name) return command.named_args and command.named_args[name] end -local function send_bluetooth_pairing_start(device) - local zcl_header = zcl_messages.ZclHeader({ - cmd = data_types.ZCLCommandId(BUTTON_COMMAND), - }) - zcl_header.frame_ctrl:set_cluster_specific() - - device:send(messages.ZigbeeMessageTx({ - address_header = messages.AddressHeader( - zb_const.HUB.ADDR, - zb_const.HUB.ENDPOINT, - device:get_short_address(), - device:get_endpoint(SONOFF_CLUSTER) or 1, - zb_const.HA_PROFILE_ID, - SONOFF_CLUSTER - ), - body = zcl_messages.ZclMessageBody({ - zcl_header = zcl_header, - zcl_body = generic_body.GenericBody(string.char(0x03, 0x01, BLUETOOTH_PAIRING_START)), - }), - })) -end - local function refresh(_, device) local attributes = { Thermostat.attributes.LocalTemperature, @@ -163,16 +138,36 @@ end local function info_changed(_, device, _, args) local old_preferences = args.old_st_store and args.old_st_store.preferences or {} local child_lock = device.preferences[CHILD_LOCK_PREFERENCE] - if child_lock == nil or old_preferences[CHILD_LOCK_PREFERENCE] == child_lock then - return + if child_lock ~= nil and old_preferences[CHILD_LOCK_PREFERENCE] ~= child_lock then + device:send(cluster_base.write_attribute( + device, + data_types.ClusterId(SONOFF_CLUSTER), + data_types.AttributeId(CHILD_LOCK_ATTR), + data_types.validate_or_build_type(tonumber(child_lock) == 1, data_types.Boolean, "payload") + )) end - device:send(cluster_base.write_attribute( - device, - data_types.ClusterId(SONOFF_CLUSTER), - data_types.AttributeId(CHILD_LOCK_ATTR), - data_types.validate_or_build_type(tonumber(child_lock) == 1, data_types.Boolean, "payload") - )) + local bluetooth_pairing = device.preferences[BLUETOOTH_PAIRING_PREFERENCE] + if bluetooth_pairing == true and old_preferences[BLUETOOTH_PAIRING_PREFERENCE] ~= true then + local zcl_header = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(0x10), + }) + zcl_header.frame_ctrl:set_cluster_specific() + device:send(messages.ZigbeeMessageTx({ + address_header = messages.AddressHeader( + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + device:get_short_address(), + device:get_endpoint(SONOFF_CLUSTER) or 1, + zb_const.HA_PROFILE_ID, + SONOFF_CLUSTER + ), + body = zcl_messages.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = generic_body.GenericBody(string.char(0x03, 0x01, 0x01)), + }), + })) + end end local function local_temperature_handler(_, device, value) @@ -222,9 +217,6 @@ local function set_heating_setpoint(_, device, command) setpoint = math.floor((setpoint / SETPOINT_STEP) + 0.000001) * SETPOINT_STEP device:send(Thermostat.attributes.OccupiedHeatingSetpoint:write(device, utils.round(setpoint * 100))) device:emit_event(ThermostatHeatingSetpoint.heatingSetpoint({ value = setpoint, unit = "C" })) - device.thread:call_with_delay(1, function() - device:send(Thermostat.attributes.OccupiedHeatingSetpoint:read(device)) - end) end local function set_mode(_, device, command) @@ -258,11 +250,6 @@ local sonoff = { [Refresh.ID] = { [Refresh.commands.refresh.NAME] = refresh, }, - [Momentary.ID] = { - [Momentary.commands.push.NAME] = function(_, device) - send_bluetooth_pairing_start(device) - end, - }, [ThermostatHeatingSetpoint.ID] = { [ThermostatHeatingSetpoint.commands.setHeatingSetpoint.NAME] = set_heating_setpoint, }, diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua index 05f5f92d49..a8ac22c64d 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua @@ -223,14 +223,18 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "SONOFF TRV-ZBT starts Bluetooth pairing from the momentary component", + "SONOFF TRV-ZBT starts Bluetooth pairing when the preference is enabled", function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "momentary", component = "bluetooth", command = "push", args = {} }, - }) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { enterBluetoothPairing = true }, + })) test.socket.zigbee:__expect_send({ mock_device.id, bluetooth_pairing_command(mock_device) }) test.wait_for_events() + + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { enterBluetoothPairing = false }, + })) + test.wait_for_events() end, { min_api_version = 14 } ) From 22f40524e3bdd72fc5d051f6bb53f984fb157352 Mon Sep 17 00:00:00 2001 From: quannan <15505180750@163.com> Date: Tue, 29 Sep 2026 09:23:50 +0800 Subject: [PATCH 07/10] fix(zigbee): rely on SONOFF mode reporting --- drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua | 3 --- 1 file changed, 3 deletions(-) diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua index da4ba8225e..f23c515407 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua @@ -233,9 +233,6 @@ local function set_mode(_, device, command) device:send(Thermostat.attributes.SystemMode:write(device, system_mode)) emit_mode(device, mode) - device.thread:call_with_delay(1, function() - device:send(Thermostat.attributes.SystemMode:read(device)) - end) end local function mode_setter(mode) From cefe59e625fb6162ccb3c4f152ebdf137445fc6c Mon Sep 17 00:00:00 2001 From: quannan <15505180750@163.com> Date: Tue, 29 Sep 2026 12:19:50 +0800 Subject: [PATCH 08/10] fix(zigbee): use default SONOFF battery reporting --- drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua | 2 +- .../zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua index f23c515407..6943ef96a7 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua @@ -69,7 +69,7 @@ local CONFIGURATIONS = { minimum_interval = 30, maximum_interval = 21600, data_type = PowerConfiguration.attributes.BatteryPercentageRemaining.base_type, - reportable_change = 5, + reportable_change = 1, }, } diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua index a8ac22c64d..b0e67b657b 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua @@ -346,7 +346,7 @@ test.register_coroutine_test( }) test.socket.zigbee:__expect_send({ mock_device.id, - PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, 30, 21600, 5), + PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, 30, 21600, 1), }) test.socket.zigbee:__expect_send({ mock_device.id, From 967f1a8d275576ed47349406bb37faeddaeeb9a3 Mon Sep 17 00:00:00 2001 From: quannan <15505180750@163.com> Date: Tue, 29 Sep 2026 18:01:13 +0800 Subject: [PATCH 09/10] refactor(zigbee): use framework battery configuration --- drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua | 8 -------- 1 file changed, 8 deletions(-) diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua index 6943ef96a7..b214a5e7b1 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua @@ -63,14 +63,6 @@ local CONFIGURATIONS = { maximum_interval = 600, data_type = Thermostat.attributes.SystemMode.base_type, }, - { - cluster = PowerConfiguration.ID, - attribute = PowerConfiguration.attributes.BatteryPercentageRemaining.ID, - minimum_interval = 30, - maximum_interval = 21600, - data_type = PowerConfiguration.attributes.BatteryPercentageRemaining.base_type, - reportable_change = 1, - }, } local function emit_setpoint_range(device) From 0f9e95df527e47f3324f1d3f31b3523aa22c2f51 Mon Sep 17 00:00:00 2001 From: quannan <15505180750@163.com> Date: Tue, 6 Oct 2026 15:52:11 +0800 Subject: [PATCH 10/10] fix(zigbee): sync SONOFF TRV-ZBT setpoint limits --- .../zigbee-thermostat/src/sonoff/init.lua | 59 +++++++++++++-- .../src/test/test_sonoff_trv_zbt.lua | 71 +++++++++++++++++-- 2 files changed, 117 insertions(+), 13 deletions(-) diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua index b214a5e7b1..0097dae163 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua @@ -28,11 +28,13 @@ local WORK_MODE_MANUAL = 0x03 local WORK_MODE_SCHEDULE = 0x04 local WORK_MODE_TEMP_MANUAL = 0x05 -local MIN_SETPOINT = 5 -local MAX_SETPOINT = 30 +local DEFAULT_MIN_SETPOINT = 5 +local DEFAULT_MAX_SETPOINT = 30 local SETPOINT_STEP = 0.5 local CHILD_LOCK_PREFERENCE = "childLock" local BLUETOOTH_PAIRING_PREFERENCE = "enterBluetoothPairing" +local MIN_HEAT_SETPOINT_LIMIT_FIELD = "min_heat_setpoint_limit" +local MAX_HEAT_SETPOINT_LIMIT_FIELD = "max_heat_setpoint_limit" local SUPPORTED_MODES = { ThermostatMode.thermostatMode.off.NAME, ThermostatMode.thermostatMode.heat.NAME, @@ -63,13 +65,44 @@ local CONFIGURATIONS = { maximum_interval = 600, data_type = Thermostat.attributes.SystemMode.base_type, }, + { + cluster = Thermostat.ID, + attribute = Thermostat.attributes.MinHeatSetpointLimit.ID, + minimum_interval = 30, + maximum_interval = 21600, + data_type = Thermostat.attributes.MinHeatSetpointLimit.base_type, + reportable_change = 50, + }, + { + cluster = Thermostat.ID, + attribute = Thermostat.attributes.MaxHeatSetpointLimit.ID, + minimum_interval = 30, + maximum_interval = 21600, + data_type = Thermostat.attributes.MaxHeatSetpointLimit.base_type, + reportable_change = 50, + }, } +local function get_setpoint_range(device) + local reported_min = device:get_field(MIN_HEAT_SETPOINT_LIMIT_FIELD) + local reported_max = device:get_field(MAX_HEAT_SETPOINT_LIMIT_FIELD) + local min = reported_min and (reported_min / 100) or DEFAULT_MIN_SETPOINT + local max = reported_max and (reported_max / 100) or DEFAULT_MAX_SETPOINT + + if min > max then + min = DEFAULT_MIN_SETPOINT + max = DEFAULT_MAX_SETPOINT + end + + return min, max +end + local function emit_setpoint_range(device) + local min, max = get_setpoint_range(device) device:emit_event(ThermostatHeatingSetpoint.heatingSetpointRange({ value = { - minimum = MIN_SETPOINT, - maximum = MAX_SETPOINT, + minimum = min, + maximum = max, step = SETPOINT_STEP, }, unit = "C", @@ -99,8 +132,6 @@ local function refresh(_, device) Thermostat.attributes.LocalTemperature, Thermostat.attributes.OccupiedHeatingSetpoint, Thermostat.attributes.SystemMode, - Thermostat.attributes.MinHeatSetpointLimit, - Thermostat.attributes.MaxHeatSetpointLimit, PowerConfiguration.attributes.BatteryPercentageRemaining, } @@ -112,6 +143,8 @@ end local function configure(driver, device) device:configure() refresh(driver, device) + device:send(Thermostat.attributes.MinHeatSetpointLimit:read(device)) + device:send(Thermostat.attributes.MaxHeatSetpointLimit:read(device)) end local function added(_, device) @@ -174,6 +207,15 @@ local function heating_setpoint_handler(_, device, value) end end +local function setpoint_limit_handler(field) + return function(_, device, value) + if value.value ~= 0x8000 and value.value ~= -32768 then + device:set_field(field, value.value, { persist = true }) + emit_setpoint_range(device) + end + end +end + local function system_mode_handler(_, device, value) if value.value == Thermostat.attributes.SystemMode.OFF then emit_mode(device, ThermostatMode.thermostatMode.off.NAME) @@ -204,7 +246,8 @@ local function set_heating_setpoint(_, device, command) setpoint = utils.f_to_c(setpoint) end - setpoint = utils.clamp_value(setpoint, MIN_SETPOINT, MAX_SETPOINT) + local min, max = get_setpoint_range(device) + setpoint = utils.clamp_value(setpoint, min, max) -- The TRV-ZBT persists heating setpoints in 0.5 C increments. setpoint = math.floor((setpoint / SETPOINT_STEP) + 0.000001) * SETPOINT_STEP device:send(Thermostat.attributes.OccupiedHeatingSetpoint:write(device, utils.round(setpoint * 100))) @@ -257,6 +300,8 @@ local sonoff = { [Thermostat.ID] = { [Thermostat.attributes.LocalTemperature.ID] = local_temperature_handler, [Thermostat.attributes.OccupiedHeatingSetpoint.ID] = heating_setpoint_handler, + [Thermostat.attributes.MinHeatSetpointLimit.ID] = setpoint_limit_handler(MIN_HEAT_SETPOINT_LIMIT_FIELD), + [Thermostat.attributes.MaxHeatSetpointLimit.ID] = setpoint_limit_handler(MAX_HEAT_SETPOINT_LIMIT_FIELD), [Thermostat.attributes.SystemMode.ID] = system_mode_handler, }, [SONOFF_CLUSTER] = { diff --git a/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua b/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua index b0e67b657b..150ad7bfe2 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua @@ -53,8 +53,6 @@ test.set_test_init_function(function() Thermostat.attributes.LocalTemperature, Thermostat.attributes.OccupiedHeatingSetpoint, Thermostat.attributes.SystemMode, - Thermostat.attributes.MinHeatSetpointLimit, - Thermostat.attributes.MaxHeatSetpointLimit, PowerConfiguration.attributes.BatteryPercentageRemaining, }) do test.socket.zigbee:__expect_send({ mock_device.id, attribute:read(mock_device) }) @@ -222,6 +220,55 @@ test.register_coroutine_test( { min_api_version = 14 } ) +test.register_coroutine_test( + "SONOFF TRV-ZBT updates setpoint range from reported limits", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MinHeatSetpointLimit:build_test_attr_report(mock_device, 1200), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ + value = { minimum = 12.0, maximum = 30, step = 0.5 }, + unit = "C", + }, { visibility = { displayed = false } })) + ) + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.MaxHeatSetpointLimit:build_test_attr_report(mock_device, 2200), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpointRange({ + value = { minimum = 12.0, maximum = 22.0, step = 0.5 }, + unit = "C", + }, { visibility = { displayed = false } })) + ) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = {}, + named_args = { setpoint = 25 }, + }, + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 2200), + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 22.0, unit = "C" })) + ) + test.wait_for_events() + end, + { min_api_version = 14 } +) + test.register_coroutine_test( "SONOFF TRV-ZBT starts Bluetooth pairing when the preference is enabled", function() @@ -303,8 +350,6 @@ test.register_coroutine_test( Thermostat.attributes.LocalTemperature, Thermostat.attributes.OccupiedHeatingSetpoint, Thermostat.attributes.SystemMode, - Thermostat.attributes.MinHeatSetpointLimit, - Thermostat.attributes.MaxHeatSetpointLimit, PowerConfiguration.attributes.BatteryPercentageRemaining, }) do test.socket.zigbee:__expect_send({ mock_device.id, attribute:read(mock_device) }) @@ -344,6 +389,14 @@ test.register_coroutine_test( mock_device.id, Thermostat.attributes.SystemMode:configure_reporting(mock_device, 1, 600, 1), }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.MinHeatSetpointLimit:configure_reporting(mock_device, 30, 21600, 50), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.MaxHeatSetpointLimit:configure_reporting(mock_device, 30, 21600, 50), + }) test.socket.zigbee:__expect_send({ mock_device.id, PowerConfiguration.attributes.BatteryPercentageRemaining:configure_reporting(mock_device, 30, 21600, 1), @@ -356,12 +409,18 @@ test.register_coroutine_test( Thermostat.attributes.LocalTemperature, Thermostat.attributes.OccupiedHeatingSetpoint, Thermostat.attributes.SystemMode, - Thermostat.attributes.MinHeatSetpointLimit, - Thermostat.attributes.MaxHeatSetpointLimit, PowerConfiguration.attributes.BatteryPercentageRemaining, }) do test.socket.zigbee:__expect_send({ mock_device.id, attribute:read(mock_device) }) end + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.MinHeatSetpointLimit:read(mock_device), + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.MaxHeatSetpointLimit:read(mock_device), + }) test.wait_for_events() end, { min_api_version = 14 }