diff --git a/drivers/SmartThings/zigbee-thermostat/fingerprints.yml b/drivers/SmartThings/zigbee-thermostat/fingerprints.yml index ed6afd59b5..9f8bf3ce3c 100644 --- a/drivers/SmartThings/zigbee-thermostat/fingerprints.yml +++ b/drivers/SmartThings/zigbee-thermostat/fingerprints.yml @@ -144,6 +144,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 12bccc65a7..f1f59d13a1 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua @@ -17,5 +17,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()