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..16ad1353b6 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff-trv-zbt.yml @@ -0,0 +1,38 @@ +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 +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 +- 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/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..0097dae163 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua @@ -0,0 +1,321 @@ +-- 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 cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +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 Refresh = capabilities.refresh +local TemperatureMeasurement = capabilities.temperatureMeasurement +local ThermostatHeatingSetpoint = capabilities.thermostatHeatingSetpoint +local ThermostatMode = capabilities.thermostatMode + +local SONOFF_CLUSTER = 0xFC11 +local CHILD_LOCK_ATTR = 0x0000 +local WORK_MODE_ATTR = 0x0018 +local WORK_MODE_OFF = 0x01 +local WORK_MODE_MANUAL = 0x03 +local WORK_MODE_SCHEDULE = 0x04 +local WORK_MODE_TEMP_MANUAL = 0x05 + +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, + 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 = 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, + maximum = max, + step = SETPOINT_STEP, + }, + 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 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 refresh(_, device) + local attributes = { + Thermostat.attributes.LocalTemperature, + Thermostat.attributes.OccupiedHeatingSetpoint, + Thermostat.attributes.SystemMode, + PowerConfiguration.attributes.BatteryPercentageRemaining, + } + + for _, attribute in ipairs(attributes) do + device:send(attribute:read(device)) + end +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) + emit_supported_modes(device) + emit_setpoint_range(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 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 + + 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) + 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 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) + 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_argument(command, "setpoint") + if setpoint >= 40 then + setpoint = utils.f_to_c(setpoint) + end + + 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))) + device:emit_event(ThermostatHeatingSetpoint.heatingSetpoint({ value = setpoint, unit = "C" })) +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 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, mode) +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, + }, + [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.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] = { + [WORK_MODE_ATTR] = work_mode_handler, + }, + }, + }, + lifecycle_handlers = { + added = added, + init = init, + doConfigure = configure, + infoChanged = info_changed, + }, + 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..150ad7bfe2 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_trv_zbt.lua @@ -0,0 +1,429 @@ +-- 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 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" +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 TemperatureMeasurement = clusters.TemperatureMeasurement + +local SONOFF_CLUSTER = 0xFC11 +local CHILD_LOCK_ATTR = 0x0000 +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, step = 0.5 }, + unit = "C", + }, { visibility = { displayed = false } })) + ) + for _, attribute in ipairs({ + Thermostat.attributes.LocalTemperature, + Thermostat.attributes.OccupiedHeatingSetpoint, + Thermostat.attributes.SystemMode, + 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() + + 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 } +) + +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() + 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 } +) + +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() + 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, + 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.zigbee:__set_channel_ordering("relaxed") + 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, TemperatureMeasurement.ID, 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, + 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), + }) + 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, + Thermostat.attributes.SystemMode, + 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 } +) + +test.run_registered_tests()