From cceca0c64d56f4fda7257dd600ca73821c6ca0c3 Mon Sep 17 00:00:00 2001 From: Jiyon Yu Date: Mon, 21 Sep 2026 17:40:59 +0800 Subject: [PATCH 1/8] Add SONOFF TP-WGZBA support to zigbee-thermostat --- .../zigbee-thermostat/fingerprints.yml | 5 + .../profiles/thermostat-sonoff.yml | 92 ++ .../src/sonoff/can_handle.lua | 12 + .../src/sonoff/fingerprints.lua | 6 + .../zigbee-thermostat/src/sonoff/init.lua | 10 + .../src/sonoff/sub_drivers.lua | 8 + .../src/sonoff/tp_wgzba/can_handle.lua | 12 + .../src/sonoff/tp_wgzba/fingerprints.lua | 6 + .../src/sonoff/tp_wgzba/init.lua | 1429 +++++++++++++++++ .../zigbee-thermostat/src/sub_drivers.lua | 1 + .../test/test_sonoff_tp_wgzba_thermostat.lua | 903 +++++++++++ 11 files changed, 2484 insertions(+) create mode 100644 drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml create mode 100644 drivers/SmartThings/zigbee-thermostat/src/sonoff/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/sonoff/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/sonoff/sub_drivers.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/init.lua create mode 100644 drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_tp_wgzba_thermostat.lua diff --git a/drivers/SmartThings/zigbee-thermostat/fingerprints.yml b/drivers/SmartThings/zigbee-thermostat/fingerprints.yml index efa89be552..eed404aa9d 100644 --- a/drivers/SmartThings/zigbee-thermostat/fingerprints.yml +++ b/drivers/SmartThings/zigbee-thermostat/fingerprints.yml @@ -139,6 +139,11 @@ zigbeeManufacturer: manufacturer: WALL HERO model: ACL-403STC1 deviceProfileName: thermostat-thirty-buttons-wallhero + - id: "SONOFF/TP-WGZBA" + deviceLabel: SONOFF Thermostat + manufacturer: SONOFF + model: TP-WGZBA + deviceProfileName: thermostat-sonoff zigbeeGeneric: - id: "genericThermostat" deviceLabel: Zigbee Thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml new file mode 100644 index 0000000000..37e1eec619 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml @@ -0,0 +1,92 @@ +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: drumborder00833.overrideStatus + version: 1 + - id: drumborder00833.overrideAction + version: 1 + - id: drumborder00833.overrideModeDuration + version: 1 + - id: drumborder00833.overrideModeTemperature + version: 1 + - id: samplereturn62595.temperatureCompensation + version: 1 + - id: drumborder00833.childLockState + version: 1 + - id: drumborder00833.openWindowDetection + version: 1 + - id: drumborder00833.openWindowState + version: 1 + - id: drumborder00833.bluetoothPairingBroadcastRequest + version: 1 + - id: drumborder00833.frostProofTemperature + version: 1 + - id: drumborder00833.temperatureControlThresholdLow + version: 1 + - id: drumborder00833.temperatureControlThresholdHigh + version: 1 + - id: drumborder00833.overheatProtectionEnable + version: 1 + - id: drumborder00833.overheatProtectionTemperature + version: 1 + - id: drumborder00833.screenWorkingBrightness + version: 1 + - id: drumborder00833.screenStandbyBrightness + version: 1 + - id: drumborder00833.screenNightModeEnable + version: 1 + - id: drumborder00833.screenNightStandbyBrightness + version: 1 + - id: drumborder00833.screenNightStartMinute + version: 1 + - id: drumborder00833.screenNightEndMinute + version: 1 + - id: drumborder00833.radarEnable + version: 1 + - id: drumborder00833.radarSensitivityLevel + version: 1 + - id: drumborder00833.radarDoNotDisturbEnable + version: 1 + - id: drumborder00833.radarDoNotDisturbStartMinute + version: 1 + - id: drumborder00833.radarDoNotDisturbEndMinute + version: 1 + - id: drumborder00833.relayHeatingContactType + version: 1 + - id: drumborder00833.relayBoilerContactType + version: 1 + - id: drumborder00833.ntcTemperature + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat 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..0c6df50a2b --- /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 + +return function(opts, driver, device, ...) + local fingerprints = require "sonoff.fingerprints" + for _, fingerprint in ipairs(fingerprints) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + return true, require("sonoff") + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/fingerprints.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/fingerprints.lua new file mode 100644 index 0000000000..664e907947 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/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/init.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua new file mode 100644 index 0000000000..2f4fc29262 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua @@ -0,0 +1,10 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local sonoff_thermostat = { + NAME = "SONOFF Thermostat Handler", + sub_drivers = require("sonoff.sub_drivers"), + can_handle = require("sonoff.can_handle"), +} + +return sonoff_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/sub_drivers.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/sub_drivers.lua new file mode 100644 index 0000000000..29dd212572 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/sub_drivers.lua @@ -0,0 +1,8 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" + +return { + lazy_load_if_possible("sonoff.tp_wgzba") +} 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..f1068ac47f --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/init.lua @@ -0,0 +1,1429 @@ +-- Copyright 2026 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. + +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 log = require "log" + +local Thermostat = clusters.Thermostat + +local ThermostatHeatingSetpoint = capabilities.thermostatHeatingSetpoint +local ThermostatMode = capabilities.thermostatMode +local ThermostatOperatingState = capabilities.thermostatOperatingState +local TemperatureCompensation = capabilities["samplereturn62595.temperatureCompensation"] +local ChildLock = capabilities["drumborder00833.childLockState"] +local OpenWindowDetection = capabilities["drumborder00833.openWindowDetection"] +local BtPairingBroadcastReq = capabilities["drumborder00833.bluetoothPairingBroadcastRequest"] +local FrostProofTemperature = capabilities["drumborder00833.frostProofTemperature"] +local OpenWindowState = capabilities["drumborder00833.openWindowState"] +local NtcTemperature = capabilities["drumborder00833.ntcTemperature"] +local TemporaryModeStatus = capabilities["drumborder00833.overrideStatus"] +local TemporaryModeDuration = capabilities["drumborder00833.overrideModeDuration"] +local TemporaryModeTemperature = capabilities["drumborder00833.overrideModeTemperature"] +local TemporaryModeAction = capabilities["drumborder00833.overrideAction"] +local TempCtrlThreshLow = capabilities["drumborder00833.temperatureControlThresholdLow"] +local TempCtrlThreshHigh = capabilities["drumborder00833.temperatureControlThresholdHigh"] +local RadarSensitivityLevel = capabilities["drumborder00833.radarSensitivityLevel"] +local RadarDoNotDisturbEnable = capabilities["drumborder00833.radarDoNotDisturbEnable"] +local RadarDndStartMin = capabilities["drumborder00833.radarDoNotDisturbStartMinute"] +local RadarDndEndMin = capabilities["drumborder00833.radarDoNotDisturbEndMinute"] +local ScreenWorkingBrightness = capabilities["drumborder00833.screenWorkingBrightness"] +local ScreenStandbyBrightness = capabilities["drumborder00833.screenStandbyBrightness"] +local ScreenNightStandbyBrightness = capabilities["drumborder00833.screenNightStandbyBrightness"] +local ScreenNightModeEnable = capabilities["drumborder00833.screenNightModeEnable"] +local ScreenNightStartMin = capabilities["drumborder00833.screenNightStartMinute"] +local ScreenNightEndMin = capabilities["drumborder00833.screenNightEndMinute"] +local RelayOutTypeR1 = capabilities["drumborder00833.relayHeatingContactType"] +local RelayOutTypeR2 = capabilities["drumborder00833.relayBoilerContactType"] +local OverheatProtectionTemperature = capabilities["drumborder00833.overheatProtectionTemperature"] +local OverheatProtectionEnable = capabilities["drumborder00833.overheatProtectionEnable"] +local RadarEnable = capabilities["drumborder00833.radarEnable"] + +local SONOFF_MFG_CODE = 0x1286 +local SONOFF_PRIVATE_CLUSTER = 0xFC11 + +local TEMPORARY_MODE_COMMAND_ID = 0x11 +local TEMPORARY_MODE_EXIT = 0x00 +local TEMPORARY_MODE_BOOST = 0x01 +local TEMPORARY_MODE_TIMER = 0x02 +local TEMPORARY_MODE_ATTR_BOOST = 0x00 +local TEMPORARY_MODE_ATTR_TIMER = 0x01 +local TEMPORARY_MODE_ATTR_NONE = 0xFF + +local PRIVATE_ATTR_CHILD_LOCK = 0x0000 +local PRIVATE_ATTR_BT_PAIRING_BROADCAST_REQ = 0x0029 +local PRIVATE_ATTR_OPEN_WINDOW_DETECTION = 0x6000 +local PRIVATE_ATTR_FROST_PROOF_TEMPERATURE = 0x6002 +local PRIVATE_ATTR_TEMPORARY_MODE_SETTINGS = 0x6014 +local PRIVATE_ATTR_TEMPERATURE_CONTROL_THRESHOLD = 0x601F +local PRIVATE_ATTR_RADAR_SENSITIVITY_LEVEL = 0x6020 +local PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_ENABLE = 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_ENABLE = 0x6026 +local PRIVATE_ATTR_SCREEN_NIGHT_MODE_PERIOD = 0x6027 +local PRIVATE_ATTR_RELAY_OUTPUT_TYPE_BITMAP = 0x6028 +local PRIVATE_ATTR_HVAC_MESSAGE_NOTIFICATION = 0x6030 +local PRIVATE_ATTR_CURRENT_NTC_TEMPERATURE_RAW = 0x6031 +local PRIVATE_ATTR_OVERHEAT_PROTECTION_TEMPERATURE = 0x6032 +local PRIVATE_ATTR_OVERHEAT_PROTECTION_ENABLE = 0x6034 +local PRIVATE_ATTR_RADAR_ENABLE = 0x6035 + +local CAPABILITY_STATE_ENABLED = "enabled" +local CAPABILITY_STATE_DISABLED = "disabled" +local OPEN_WINDOW_STATE_OPEN = "open" +local OPEN_WINDOW_STATE_CLOSED = "closed" +local RADAR_SENSITIVITY_LOW = "low" +local RADAR_SENSITIVITY_MEDIUM = "medium" +local RADAR_SENSITIVITY_HIGH = "high" +local RELAY_OUTPUT_TYPE_NORMALLY_OPEN = "normallyOpen" +local RELAY_OUTPUT_TYPE_NORMALLY_CLOSED = "normallyClosed" +local TEMPORARY_MODE_STATE_NONE = "None" +local TEMPORARY_MODE_STATE_BOOST = "Boost" +local TEMPORARY_MODE_STATE_TIMER = "Timer" +local TEMPORARY_MODE_ACTION_EXIT = "exit" +local TEMPORARY_MODE_ACTION_BOOST = "boost" +local TEMPORARY_MODE_ACTION_TIMER = "timer" +local UNIT_CELSIUS = "℃" +local UNIT_MINUTES = "min" +local UNIT_LEVEL = "level" +local PRIVATE_HVAC_OPEN_WINDOW_TLV = 0x00 +local PRIVATE_HVAC_OPEN_WINDOW_TLV_LENGTH = 0x01 +local PRIVATE_NTC_INVALID_CODE = -32768 +local PRIVATE_NTC_OPEN_CIRCUIT_CODE = -32512 +local PRIVATE_NTC_SHORT_CIRCUIT_CODE = -32256 +local PRIVATE_NTC_ADC_INVALID_CODE = -32000 +local PRIVATE_NTC_MIN_VALID = -27315 +local PRIVATE_TEMP_CTRL_THRESH_LOW_DEFAULT = -2.0 +local PRIVATE_TEMP_CTRL_THRESH_HIGH_DEFAULT = 2.0 +local PRIVATE_TEMP_CTRL_THRESH_LOW_MIN = -2.6 +local PRIVATE_TEMP_CTRL_THRESH_LOW_MAX = -0.2 +local PRIVATE_TEMP_CTRL_THRESH_HIGH_MIN = 0.0 +local PRIVATE_TEMP_CTRL_THRESH_HIGH_MAX = 2.6 +local PRIVATE_TIME_STEP_MINUTES = 30 +local PRIVATE_TIME_MAX_MINUTES = 1439 +local SUPPORTED_MODES = { + ThermostatMode.thermostatMode.off.NAME, + ThermostatMode.thermostatMode.auto.NAME, + ThermostatMode.thermostatMode.heat.NAME +} + +local THERMOSTAT_MODE_MAP = { + [ThermostatMode.thermostatMode.off.NAME] = Thermostat.attributes.SystemMode.OFF, + [ThermostatMode.thermostatMode.auto.NAME] = Thermostat.attributes.SystemMode.AUTO, + [ThermostatMode.thermostatMode.heat.NAME] = Thermostat.attributes.SystemMode.HEAT +} + +local SIMPLE_PRIVATE_ATTRIBUTE_INFO = { + [PRIVATE_ATTR_CHILD_LOCK] = { + capability = ChildLock, + attribute_name = "childLockState", + argument_name = "childLockState", + data_type = data_types.Boolean, + kind = "bool" + }, + [PRIVATE_ATTR_BT_PAIRING_BROADCAST_REQ] = { + capability = BtPairingBroadcastReq, + attribute_name = "btPairingBroadcastReq", + argument_name = "btPairingBroadcastReq", + data_type = data_types.Uint8, + kind = "enum", + enum_values = { + [0] = CAPABILITY_STATE_DISABLED, + [1] = CAPABILITY_STATE_ENABLED + }, + reverse_enum_values = { + [CAPABILITY_STATE_DISABLED] = 0, + [CAPABILITY_STATE_ENABLED] = 1 + } + }, + [PRIVATE_ATTR_OPEN_WINDOW_DETECTION] = { + capability = OpenWindowDetection, + attribute_name = "openWindowDetection", + argument_name = "openWindowDetection", + data_type = data_types.Boolean, + kind = "bool" + }, + [PRIVATE_ATTR_FROST_PROOF_TEMPERATURE] = { + capability = FrostProofTemperature, + attribute_name = "frostProofTemperature", + argument_name = "frostProofTemperature", + data_type = data_types.Int16, + kind = "scaled", + min = 5.0, + max = 15.0, + scale = 100, + unit = UNIT_CELSIUS + }, + [PRIVATE_ATTR_TEMPORARY_MODE_SETTINGS] = { + capability = TemporaryModeStatus, + attribute_name = "temporaryTemperatureModeSettings", + argument_name = "temporaryTemperatureModeSettings", + data_type = data_types.Uint8, + kind = "enum", + enum_values = { + [TEMPORARY_MODE_ATTR_NONE] = TEMPORARY_MODE_STATE_NONE, + [TEMPORARY_MODE_ATTR_BOOST] = TEMPORARY_MODE_STATE_BOOST, + [TEMPORARY_MODE_ATTR_TIMER] = TEMPORARY_MODE_STATE_TIMER + } + }, + [PRIVATE_ATTR_RADAR_SENSITIVITY_LEVEL] = { + capability = RadarSensitivityLevel, + attribute_name = "radarSensitivityLevel", + argument_name = "radarSensitivityLevel", + data_type = data_types.Uint8, + kind = "enum", + enum_values = { + [0] = RADAR_SENSITIVITY_LOW, + [1] = RADAR_SENSITIVITY_MEDIUM, + [2] = RADAR_SENSITIVITY_HIGH + }, + reverse_enum_values = { + [RADAR_SENSITIVITY_LOW] = 0, + [RADAR_SENSITIVITY_MEDIUM] = 1, + [RADAR_SENSITIVITY_HIGH] = 2 + } + }, + [PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_ENABLE] = { + capability = RadarDoNotDisturbEnable, + attribute_name = "radarDoNotDisturbEnable", + argument_name = "radarDoNotDisturbEnable", + data_type = data_types.Boolean, + kind = "bool" + }, + [PRIVATE_ATTR_SCREEN_WORKING_BRIGHTNESS] = { + capability = ScreenWorkingBrightness, + attribute_name = "screenWorkingBrightness", + argument_name = "screenWorkingBrightness", + data_type = data_types.Uint8, + kind = "int", + min = 0, + max = 8, + unit = UNIT_LEVEL + }, + [PRIVATE_ATTR_SCREEN_STANDBY_BRIGHTNESS] = { + capability = ScreenStandbyBrightness, + attribute_name = "screenStandbyBrightness", + argument_name = "screenStandbyBrightness", + data_type = data_types.Uint8, + kind = "int", + min = 0, + max = 8, + unit = UNIT_LEVEL + }, + [PRIVATE_ATTR_SCREEN_NIGHT_STANDBY_BRIGHTNESS] = { + capability = ScreenNightStandbyBrightness, + attribute_name = "screenNightStandbyBrightness", + argument_name = "screenNightStandbyBrightness", + data_type = data_types.Uint8, + kind = "int", + min = 0, + max = 8, + unit = UNIT_LEVEL + }, + [PRIVATE_ATTR_SCREEN_NIGHT_MODE_ENABLE] = { + capability = ScreenNightModeEnable, + attribute_name = "screenNightModeEnable", + argument_name = "screenNightModeEnable", + data_type = data_types.Boolean, + kind = "bool" + }, + [PRIVATE_ATTR_OVERHEAT_PROTECTION_TEMPERATURE] = { + capability = OverheatProtectionTemperature, + attribute_name = "overheatProtectionTemperature", + argument_name = "overheatProtectionTemperature", + data_type = data_types.Int16, + kind = "scaled", + min = 20.0, + max = 50.0, + scale = 100, + unit = UNIT_CELSIUS + }, + [PRIVATE_ATTR_OVERHEAT_PROTECTION_ENABLE] = { + capability = OverheatProtectionEnable, + attribute_name = "overheatProtectionEnable", + argument_name = "overheatProtectionEnable", + data_type = data_types.Boolean, + kind = "bool" + }, + [PRIVATE_ATTR_RADAR_ENABLE] = { + capability = RadarEnable, + attribute_name = "radarEnable", + argument_name = "radarEnable", + data_type = data_types.Boolean, + kind = "bool" + } +} + +local PRIVATE_REFRESH_ATTRIBUTES = { + PRIVATE_ATTR_CHILD_LOCK, + PRIVATE_ATTR_BT_PAIRING_BROADCAST_REQ, + PRIVATE_ATTR_OPEN_WINDOW_DETECTION, + PRIVATE_ATTR_FROST_PROOF_TEMPERATURE, + PRIVATE_ATTR_TEMPORARY_MODE_SETTINGS, + PRIVATE_ATTR_RADAR_SENSITIVITY_LEVEL, + PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_ENABLE, + PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_PERIOD, + PRIVATE_ATTR_SCREEN_WORKING_BRIGHTNESS, + PRIVATE_ATTR_SCREEN_STANDBY_BRIGHTNESS, + PRIVATE_ATTR_SCREEN_NIGHT_STANDBY_BRIGHTNESS, + PRIVATE_ATTR_SCREEN_NIGHT_MODE_ENABLE, + PRIVATE_ATTR_SCREEN_NIGHT_MODE_PERIOD, + PRIVATE_ATTR_RELAY_OUTPUT_TYPE_BITMAP, + PRIVATE_ATTR_HVAC_MESSAGE_NOTIFICATION, + PRIVATE_ATTR_CURRENT_NTC_TEMPERATURE_RAW, + PRIVATE_ATTR_OVERHEAT_PROTECTION_TEMPERATURE, + PRIVATE_ATTR_OVERHEAT_PROTECTION_ENABLE, + PRIVATE_ATTR_RADAR_ENABLE +} + +local INITIAL_SIMPLE_PRIVATE_ATTRIBUTE_DEFAULTS = { + [PRIVATE_ATTR_CHILD_LOCK] = false, + [PRIVATE_ATTR_BT_PAIRING_BROADCAST_REQ] = 0, + [PRIVATE_ATTR_OPEN_WINDOW_DETECTION] = false, + [PRIVATE_ATTR_FROST_PROOF_TEMPERATURE] = 500, + [PRIVATE_ATTR_RADAR_SENSITIVITY_LEVEL] = 2, + [PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_ENABLE] = false, + [PRIVATE_ATTR_SCREEN_WORKING_BRIGHTNESS] = 5, + [PRIVATE_ATTR_SCREEN_STANDBY_BRIGHTNESS] = 0, + [PRIVATE_ATTR_SCREEN_NIGHT_STANDBY_BRIGHTNESS] = 0, + [PRIVATE_ATTR_SCREEN_NIGHT_MODE_ENABLE] = false, + [PRIVATE_ATTR_OVERHEAT_PROTECTION_TEMPERATURE] = 3350, + [PRIVATE_ATTR_OVERHEAT_PROTECTION_ENABLE] = false, + [PRIVATE_ATTR_RADAR_ENABLE] = false +} + +local emit_simple_private_attribute +local write_temperature_control_threshold + +-- Emits an event only when the active profile declares the capability. +local function emit_event_if_supported(device, capability, event) + -- This keeps sub-driver code safe if the profile is temporarily pared down. + if device:supports_capability_by_id(capability.ID) then + device:emit_event(event) + end +end + +-- Emits the supported thermostat mode list expected by TP-WGZBA. +local function emit_supported_modes(device) + -- The device only exposes off, auto, and heat in the first version. + device:emit_event(ThermostatMode.supportedThermostatModes(SUPPORTED_MODES, { visibility = { displayed = false } })) +end + +-- Seeds writable main-page capability defaults only when the driver has no cached state yet. +local function emit_main_page_defaults_if_needed(device) + -- Keep initial controls clickable during join-time gaps or when a vendor report is temporarily missing. + if device:get_latest_state("main", OpenWindowState.ID, "openWindowState", nil) == nil then + emit_event_if_supported(device, OpenWindowState, OpenWindowState.openWindowState(OPEN_WINDOW_STATE_CLOSED)) + end + if device:get_latest_state("main", NtcTemperature.ID, "ntcTemperature", nil) == nil then + emit_event_if_supported(device, NtcTemperature, NtcTemperature.ntcTemperature("Not Connected")) + end + if device:get_latest_state("main", TemperatureCompensation.ID, "temperatureCompensation", nil) == nil then + emit_event_if_supported(device, TemperatureCompensation, TemperatureCompensation.temperatureCompensation({ value = 0.0, unit = UNIT_CELSIUS })) + end + + for attribute_id, raw_value in pairs(INITIAL_SIMPLE_PRIVATE_ATTRIBUTE_DEFAULTS) do + local info = SIMPLE_PRIVATE_ATTRIBUTE_INFO[attribute_id] + if info ~= nil and device:get_latest_state("main", info.capability.ID, info.attribute_name, nil) == nil then + emit_simple_private_attribute(device, attribute_id, raw_value) + end + end + + -- Seed the split threshold pair with the firmware defaults until the real struct arrives. + if device:get_latest_state("main", TempCtrlThreshLow.ID, "tempCtrlThreshLow", nil) == nil then + emit_event_if_supported( + device, + TempCtrlThreshLow, + TempCtrlThreshLow.tempCtrlThreshLow({ value = PRIVATE_TEMP_CTRL_THRESH_LOW_DEFAULT, unit = UNIT_CELSIUS }) + ) + end + if device:get_latest_state("main", TempCtrlThreshHigh.ID, "tempCtrlThreshHigh", nil) == nil then + emit_event_if_supported( + device, + TempCtrlThreshHigh, + TempCtrlThreshHigh.tempCtrlThreshHigh({ value = PRIVATE_TEMP_CTRL_THRESH_HIGH_DEFAULT, unit = UNIT_CELSIUS }) + ) + end + + -- Seed both time-period pairs so the UI remains operable before the first structure report. + if device:get_latest_state("main", RadarDndStartMin.ID, "radarDndStartMin", nil) == nil then + emit_event_if_supported(device, RadarDndStartMin, RadarDndStartMin.radarDndStartMin({ value = 0, unit = UNIT_MINUTES })) + end + if device:get_latest_state("main", RadarDndEndMin.ID, "radarDndEndMin", nil) == nil then + emit_event_if_supported(device, RadarDndEndMin, RadarDndEndMin.radarDndEndMin({ value = 0, unit = UNIT_MINUTES })) + end + if device:get_latest_state("main", ScreenNightStartMin.ID, "screenNightStartMin", nil) == nil then + emit_event_if_supported(device, ScreenNightStartMin, ScreenNightStartMin.screenNightStartMin({ value = 0, unit = UNIT_MINUTES })) + end + if device:get_latest_state("main", ScreenNightEndMin.ID, "screenNightEndMin", nil) == nil then + emit_event_if_supported(device, ScreenNightEndMin, ScreenNightEndMin.screenNightEndMin({ value = 0, unit = UNIT_MINUTES })) + end + + -- Seed relay contact polarity with the firmware default bitmap until the real value is reported. + if device:get_latest_state("main", RelayOutTypeR1.ID, "relayOneOutputType", nil) == nil then + emit_event_if_supported(device, RelayOutTypeR1, RelayOutTypeR1.relayOneOutputType(RELAY_OUTPUT_TYPE_NORMALLY_OPEN)) + end + if device:get_latest_state("main", RelayOutTypeR2.ID, "relayTwoOutputType", nil) == nil then + emit_event_if_supported(device, RelayOutTypeR2, RelayOutTypeR2.relayTwoOutputType(RELAY_OUTPUT_TYPE_NORMALLY_OPEN)) + end +end + +-- Reads a SONOFF private cluster attribute using the eWeLink manufacturer code. +local function read_private_attribute(device, attribute_id) + -- Manufacturer-specific reads are required for the 0xFC11 vendor cluster. + return cluster_base.read_manufacturer_specific_attribute(device, SONOFF_PRIVATE_CLUSTER, attribute_id, SONOFF_MFG_CODE) +end + +-- Writes a SONOFF private cluster attribute with the provided typed payload. +local function write_private_attribute(device, attribute_id, data_type, value) + -- Preserve numeric precision by letting the Zigbee type builder own encoding. + return cluster_base.write_manufacturer_specific_attribute(device, SONOFF_PRIVATE_CLUSTER, attribute_id, SONOFF_MFG_CODE, data_type, value) +end + +-- Sends a SONOFF private cluster-specific command with a raw payload body. +local function send_private_cluster_command(device, command_id, payload) + -- The private command lives on the vendor cluster rather than a standard Zigbee attribute write path. + local endpoint = device:get_endpoint(SONOFF_PRIVATE_CLUSTER) or device.fingerprinted_endpoint_id or 1 + local zcl_header = ZigbeeZcl.ZclHeader({ + cmd = data_types.ZCLCommandId(command_id) + }) + zcl_header.frame_ctrl:set_cluster_specific() + + return ZigbeeMessages.ZigbeeMessageTx({ + address_header = ZigbeeMessages.AddressHeader( + ZigbeeConstants.HUB.ADDR, + ZigbeeConstants.HUB.ENDPOINT, + device:get_short_address(), + endpoint, + ZigbeeConstants.HA_PROFILE_ID, + SONOFF_PRIVATE_CLUSTER + ), + body = ZigbeeZcl.ZclMessageBody({ + zcl_header = zcl_header, + zcl_body = generic_body.GenericBody(payload) + }) + }) +end + +-- Converts a wrapped Zigbee value to its raw payload value. +local function get_attribute_value(value) + -- Attr handlers receive both raw tables and typed wrappers depending on the path. + if type(value) == "table" and value.value ~= nil then + return value.value + end + return value +end + +-- Returns raw bytes from a Zigbee payload object when the private attr uses an opaque byte string. +local function get_payload_bytes(value) + -- Vendor message attributes may arrive as raw strings or nested wrappers. + if type(value) == "string" then + return value + end + if type(value) == "table" then + if type(value.value) == "string" then + return value.value + end + if type(value.value) == "table" and type(value.value.value) == "string" then + return value.value.value + end + end + return nil +end + +-- Converts a SmartThings number-like value to a Lua number. +local function coerce_numeric_value(value) + -- Capability arguments may arrive as either numbers or decimal strings. + if type(value) == "number" then + return value + end + if type(value) == "string" and value ~= "" then + return tonumber(value) + end + return nil +end + +-- Returns the latest numeric state for a capability, or the provided fallback. +local function get_latest_numeric_state(device, capability, attribute_name, fallback) + -- Temporary mode duration and target temperature are UI-side cached values. + local cached_value = device:get_latest_state("main", capability.ID, attribute_name, fallback) + local numeric_value = coerce_numeric_value(cached_value) + if numeric_value == nil then + return fallback + end + return numeric_value +end + +-- Snaps a minute value to the device's 30-minute grid and valid range. +local function normalize_half_hour_minutes(value) + -- The device stores radar and screen periods in 30-minute steps only. + local numeric_value = coerce_numeric_value(value) + if numeric_value == nil then + return nil + end + + numeric_value = utils.clamp_value(utils.round(numeric_value), 0, PRIVATE_TIME_MAX_MINUTES) + local snapped_value = math.floor((numeric_value + (PRIVATE_TIME_STEP_MINUTES / 2)) / PRIVATE_TIME_STEP_MINUTES) * PRIVATE_TIME_STEP_MINUTES + return math.min(snapped_value, PRIVATE_TIME_MAX_MINUTES - (PRIVATE_TIME_MAX_MINUTES % PRIVATE_TIME_STEP_MINUTES)) +end + +-- Encodes an unsigned 16-bit value in little-endian order. +local function encode_uint16_le(value) + -- Avoid string.pack so this runs on the stock SmartThings Lua runtime. + local normalized = math.floor(value) % 0x10000 + return string.char(normalized % 0x100, math.floor(normalized / 0x100)) +end + +-- Encodes a signed 16-bit value in little-endian order. +local function encode_int16_le(value) + -- Vendor commands store temperatures as signed centi-degrees. + local normalized = math.floor(value) + if normalized < 0 then + normalized = normalized + 0x10000 + end + return encode_uint16_le(normalized) +end + +-- Encodes an unsigned 32-bit value in little-endian order. +local function encode_uint32_le(value) + -- Vendor commands store duration in seconds across four bytes. + local normalized = math.floor(value) + local b1 = normalized % 0x100 + local b2 = math.floor(normalized / 0x100) % 0x100 + local b3 = math.floor(normalized / 0x10000) % 0x100 + local b4 = math.floor(normalized / 0x1000000) % 0x100 + return string.char(b1, b2, b3, b4) +end + +-- Decodes an unsigned 16-bit value from a raw little-endian payload offset. +local function decode_uint16_le(raw, offset) + -- Some private structure reports arrive as raw bytes rather than decoded tables. + local low_byte, high_byte = raw:byte(offset, offset + 1) + if low_byte == nil or high_byte == nil then + return nil + end + return low_byte + (high_byte * 0x100) +end + +-- Decodes a signed 16-bit value from a raw little-endian payload offset. +local function decode_int16_le(raw, offset) + -- Threshold payloads store signed centi-degree values in two-byte little-endian form. + local value = decode_uint16_le(raw, offset) + if value == nil then + return nil + end + if value >= 0x8000 then + return value - 0x10000 + end + return value +end + +-- Encodes the relay-output bitmap for relay1 and relay2. +local function encode_relay_output_bitmap(relay1, relay2) + -- Bit0 is relay1 and bit1 is relay2 in the vendor bitmap. + return (relay1 % 2) + ((relay2 % 2) * 2) +end + +-- Encodes the start/end minute structure used by radar DND and screen night periods. +local function encode_time_period(start_minute, end_minute) + -- Both vendor attributes use a Uint16 pair in local minutes from midnight. + return data_types.Structure({ + data_types.Uint16(start_minute), + data_types.Uint16(end_minute) + }) +end + +-- Emits a boolean-style capability event using enabled/disabled states. +local function emit_enabled_disabled_event(device, info, raw_value) + -- The custom SONOFF boolean capabilities use string states instead of bare booleans. + local event_value = raw_value and CAPABILITY_STATE_ENABLED or CAPABILITY_STATE_DISABLED + emit_event_if_supported(device, info.capability, info.capability[info.attribute_name](event_value)) +end + +-- Emits a scalar capability event after scaling and clamping. +local function emit_numeric_event(device, info, raw_value) + -- Convert vendor integer storage to the capability's display unit. + local event_value = raw_value + if info.scale ~= nil then + event_value = raw_value / info.scale + end + if info.unit ~= nil then + emit_event_if_supported(device, info.capability, info.capability[info.attribute_name]({ value = event_value, unit = info.unit })) + else + emit_event_if_supported(device, info.capability, info.capability[info.attribute_name](event_value)) + end +end + +-- Emits an enum-style capability event from the vendor value map. +local function emit_enum_event(device, info, raw_value) + -- Ignore unknown enum values to avoid poisoning capability state. + local mapped_value = info.enum_values and info.enum_values[raw_value] or nil + if mapped_value ~= nil then + emit_event_if_supported(device, info.capability, info.capability[info.attribute_name](mapped_value)) + end +end + +-- Converts a vendor attribute report into the corresponding capability event. +emit_simple_private_attribute = function(device, attribute_id, raw_value) + -- A single table keeps most private mappings declarative and easy to audit. + local info = SIMPLE_PRIVATE_ATTRIBUTE_INFO[attribute_id] + if info == nil or info.capability == nil or info.attribute_name == nil then + return + end + + if info.kind == "bool" then + emit_enabled_disabled_event(device, info, raw_value == true) + elseif info.kind == "enum" then + emit_enum_event(device, info, raw_value) + else + emit_numeric_event(device, info, raw_value) + end +end + +-- Converts a capability command value into the vendor payload for a simple private attribute. +local function encode_simple_private_capability_value(info, requested_value) + -- Keep encoding rules centralized so handlers remain trivial. + if info.kind == "bool" then + if requested_value == CAPABILITY_STATE_ENABLED then + return true + end + if requested_value == CAPABILITY_STATE_DISABLED then + return false + end + return nil + end + + if info.kind == "enum" then + return info.reverse_enum_values and info.reverse_enum_values[requested_value] or nil + end + + if type(requested_value) ~= "number" then + return nil + end + + local numeric_value = requested_value + if info.min ~= nil then + numeric_value = math.max(info.min, numeric_value) + end + if info.max ~= nil then + numeric_value = math.min(info.max, numeric_value) + end + if info.scale ~= nil then + numeric_value = utils.round(numeric_value * info.scale) + else + numeric_value = utils.round(numeric_value) + end + return numeric_value +end + +-- Writes a simple private capability value through the shared vendor attribute path. +local function set_simple_private_capability(driver, device, command, attribute_id) + -- Most SONOFF main-page controls map directly to one FC11 private attribute. + local info = SIMPLE_PRIVATE_ATTRIBUTE_INFO[attribute_id] + if info == nil then + return + end + + local requested_value = command.args[info.argument_name] + local encoded_value = encode_simple_private_capability_value(info, requested_value) + if encoded_value == nil then + log.warn("Invalid TP-WGZBA capability value for " .. info.argument_name) + return + end + + device:send(write_private_attribute(device, attribute_id, info.data_type, encoded_value)) + emit_simple_private_attribute(device, attribute_id, encoded_value) +end + +-- Writes the vendor threshold struct after one side of the pair changes. +local function write_temperature_control_threshold_from_capability(device, low_value, high_value) + -- The device stores both hysteresis values in one structure attribute. + return write_temperature_control_threshold(device, low_value, high_value) +end + +-- Writes the vendor time-period struct after one side of the pair changes. +local function write_time_period_attribute(device, attribute_id, start_minute, end_minute) + -- The device expects both start and end minutes together in one structure write. + local normalized_start = normalize_half_hour_minutes(start_minute) + local normalized_end = normalize_half_hour_minutes(end_minute) + if normalized_start == nil or normalized_end == nil then + return false + end + + device:send(write_private_attribute( + device, + attribute_id, + data_types.Structure, + encode_time_period(normalized_start, normalized_end) + )) + if attribute_id == PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_PERIOD then + emit_event_if_supported(device, RadarDndStartMin, RadarDndStartMin.radarDndStartMin({ value = normalized_start, unit = UNIT_MINUTES })) + emit_event_if_supported(device, RadarDndEndMin, RadarDndEndMin.radarDndEndMin({ value = normalized_end, unit = UNIT_MINUTES })) + elseif attribute_id == PRIVATE_ATTR_SCREEN_NIGHT_MODE_PERIOD then + emit_event_if_supported(device, ScreenNightStartMin, ScreenNightStartMin.screenNightStartMin({ value = normalized_start, unit = UNIT_MINUTES })) + emit_event_if_supported(device, ScreenNightEndMin, ScreenNightEndMin.screenNightEndMin({ value = normalized_end, unit = UNIT_MINUTES })) + end + return true +end + +-- Converts relay capability values to the vendor bit representation. +local function coerce_relay_output_value(value) + -- The custom relay capabilities expose open/closed contact names. + if value == RELAY_OUTPUT_TYPE_NORMALLY_OPEN or value == 0 or value == "0" then + return 0 + end + if value == RELAY_OUTPUT_TYPE_NORMALLY_CLOSED or value == 1 or value == "1" then + return 1 + end + return nil +end + +-- Writes the split relay states back into the vendor bitmap. +local function write_relay_output_type(device, relay1_value, relay2_value) + -- Both relay bits must be rewritten together because the device stores a bitmap. + local relay1 = coerce_relay_output_value(relay1_value) + local relay2 = coerce_relay_output_value(relay2_value) + if relay1 == nil or relay2 == nil then + return false + end + + device:send(write_private_attribute(device, PRIVATE_ATTR_RELAY_OUTPUT_TYPE_BITMAP, data_types.Bitmap8, encode_relay_output_bitmap(relay1, relay2))) + emit_event_if_supported( + device, + RelayOutTypeR1, + RelayOutTypeR1.relayOneOutputType(relay1 == 1 and RELAY_OUTPUT_TYPE_NORMALLY_CLOSED or RELAY_OUTPUT_TYPE_NORMALLY_OPEN) + ) + emit_event_if_supported( + device, + RelayOutTypeR2, + RelayOutTypeR2.relayTwoOutputType(relay2 == 1 and RELAY_OUTPUT_TYPE_NORMALLY_CLOSED or RELAY_OUTPUT_TYPE_NORMALLY_OPEN) + ) + return true +end + +-- Decodes the vendor HVAC message payload into a simple open-window state. +local function decode_open_window_state(value) + -- The private HVAC payload is a short TLV packet where tag 0 reports the window state. + local raw = get_payload_bytes(value) + if raw == nil or #raw < 3 then + return false + end + + return raw:byte(1) == PRIVATE_HVAC_OPEN_WINDOW_TLV + and raw:byte(2) == PRIVATE_HVAC_OPEN_WINDOW_TLV_LENGTH + and raw:byte(3) ~= 0 +end + +-- Decodes the vendor NTC raw temperature into a single display string. +local function decode_ntc_temperature_display(value) + -- Any invalid or fault code should collapse to one user-facing disconnected state. + local raw_value = get_attribute_value(value) + if raw_value == nil then + return "Not Connected" + end + if raw_value == PRIVATE_NTC_INVALID_CODE then + return "Not Connected" + end + if raw_value == PRIVATE_NTC_OPEN_CIRCUIT_CODE then + return "Not Connected" + end + if raw_value == PRIVATE_NTC_SHORT_CIRCUIT_CODE then + return "Not Connected" + end + if raw_value == PRIVATE_NTC_ADC_INVALID_CODE then + return "Not Connected" + end + if raw_value < PRIVATE_NTC_MIN_VALID then + return "Not Connected" + end + return string.format("%.2f C", raw_value / 100.0) +end + +-- Converts the vendor temporary-mode byte to the published override status string. +local function decode_temporary_mode_state(raw_value) + -- The device reports only none, boost, and timer for this attribute. + if raw_value == TEMPORARY_MODE_ATTR_BOOST then + return TEMPORARY_MODE_STATE_BOOST + end + if raw_value == TEMPORARY_MODE_ATTR_TIMER then + return TEMPORARY_MODE_STATE_TIMER + end + if raw_value == TEMPORARY_MODE_ATTR_NONE then + return TEMPORARY_MODE_STATE_NONE + end + return nil +end + +-- Seeds the override capability defaults when the platform cache is still empty. +local function emit_temporary_mode_defaults_if_needed(device) + -- These capability values are UI inputs, so they need local defaults before any write happens. + if device:get_latest_state("main", TemporaryModeStatus.ID, "temporaryTemperatureModeSettings", nil) == nil then + emit_event_if_supported(device, TemporaryModeStatus, TemporaryModeStatus.temporaryTemperatureModeSettings(TEMPORARY_MODE_STATE_NONE)) + end + if device:get_latest_state("main", TemporaryModeDuration.ID, "temporaryModeTimeSet", nil) == nil then + emit_event_if_supported(device, TemporaryModeDuration, TemporaryModeDuration.temporaryModeTimeSet({ value = 30, unit = UNIT_MINUTES })) + end + if device:get_latest_state("main", TemporaryModeTemperature.ID, "temporaryModeTempSet", nil) == nil then + emit_event_if_supported(device, TemporaryModeTemperature, TemporaryModeTemperature.temporaryModeTempSet({ value = 30.0, unit = UNIT_CELSIUS })) + end + if device:get_latest_state("main", TemporaryModeAction.ID, "temporaryModeAction", nil) == nil then + emit_event_if_supported(device, TemporaryModeAction, TemporaryModeAction.temporaryModeAction(TEMPORARY_MODE_ACTION_EXIT)) + end +end + +-- Writes both temperature-control thresholds together using the vendor structure payload. +write_temperature_control_threshold = function(device, low_value, high_value) + -- Attribute 0x601F stores the low/high hysteresis pair atomically in one struct. + if type(low_value) ~= "number" or type(high_value) ~= "number" then + return false + end + + local clamped_low = utils.clamp_value(low_value, PRIVATE_TEMP_CTRL_THRESH_LOW_MIN, PRIVATE_TEMP_CTRL_THRESH_LOW_MAX) + local clamped_high = utils.clamp_value(high_value, PRIVATE_TEMP_CTRL_THRESH_HIGH_MIN, PRIVATE_TEMP_CTRL_THRESH_HIGH_MAX) + local threshold_value = data_types.Structure({ + data_types.Int16(utils.round(clamped_low * 100)), + data_types.Int16(utils.round(clamped_high * 100)) + }) + + device:send(write_private_attribute( + device, + PRIVATE_ATTR_TEMPERATURE_CONTROL_THRESHOLD, + data_types.Structure, + threshold_value + )) + emit_event_if_supported(device, TempCtrlThreshLow, TempCtrlThreshLow.tempCtrlThreshLow({ value = clamped_low, unit = UNIT_CELSIUS })) + emit_event_if_supported(device, TempCtrlThreshHigh, TempCtrlThreshHigh.tempCtrlThreshHigh({ value = clamped_high, unit = UNIT_CELSIUS })) + return true +end + +-- Emits the override status reported by the vendor temporary-mode attribute. +local function private_temporary_mode_settings_handler(driver, device, value, zb_rx) + -- Ignore unknown states so the app never shows a misleading override mode. + local event_value = decode_temporary_mode_state(get_attribute_value(value)) + if event_value ~= nil then + emit_event_if_supported(device, TemporaryModeStatus, TemporaryModeStatus.temporaryTemperatureModeSettings(event_value)) + end +end + +-- Emits the read-only open-window state extracted from the private HVAC message payload. +local function private_hvac_message_handler(driver, device, value, zb_rx) + -- Keep the detection state separate from the writable open-window feature toggle. + local is_open = decode_open_window_state(value) + local event_value = is_open and OPEN_WINDOW_STATE_OPEN or OPEN_WINDOW_STATE_CLOSED + emit_event_if_supported(device, OpenWindowState, OpenWindowState.openWindowState(event_value)) +end + +-- Emits the read-only NTC display string derived from the private raw temperature attribute. +local function private_ntc_temperature_handler(driver, device, value, zb_rx) + -- Keep the UI on one field so App layout stays compact and predictable. + emit_event_if_supported(device, NtcTemperature, NtcTemperature.ntcTemperature(decode_ntc_temperature_display(value))) +end + +-- Extracts a Lua number from one member of a Zigbee structure payload. +local function get_structure_member_number(member) + -- Attribute reports may wrap each structure element in nested typed tables. + if type(member) == "number" then + return member + end + if type(member) == "table" and member.value ~= nil then + return get_structure_member_number(member.value) + end + if type(member) == "string" then + local hex_value = member:match("0x([%x]+)") + if hex_value ~= nil then + return tonumber(hex_value, 16) + end + local decimal_value = member:match("(-?%d+)") + if decimal_value ~= nil then + return tonumber(decimal_value) + end + end + + local text = tostring(member) + if type(text) == "string" then + local hex_value = text:match("0x([%x]+)") + if hex_value ~= nil then + return tonumber(hex_value, 16) + end + local decimal_value = text:match("(-?%d+)") + if decimal_value ~= nil then + return tonumber(decimal_value) + end + end + return nil +end + +-- Extracts the first two numeric members from a Zigbee structure payload. +local function get_structure_pair_numbers(value) + -- SmartThings may surface structure members as indexed fields, nested values, or stringified typed values. + local candidates = { + value, + get_attribute_value(value) + } + + for _, candidate in ipairs(candidates) do + if type(candidate) == "table" then + local first = get_structure_member_number(candidate[1]) + local second = get_structure_member_number(candidate[2]) + if first ~= nil and second ~= nil then + return first, second + end + + if type(candidate.value) == "table" then + first = get_structure_member_number(candidate.value[1]) + second = get_structure_member_number(candidate.value[2]) + if first ~= nil and second ~= nil then + return first, second + end + end + end + end + + local text = tostring(value) + if type(text) == "string" then + local numbers = {} + for hex_value in text:gmatch(":%s*0x([%x]+)") do + table.insert(numbers, tonumber(hex_value, 16)) + end + if #numbers >= 2 then + return numbers[1], numbers[2] + end + + numbers = {} + for decimal_value in text:gmatch(":%s*(-?%d+)") do + table.insert(numbers, tonumber(decimal_value)) + end + if #numbers >= 2 then + return numbers[1], numbers[2] + end + end + + return nil, nil +end + +-- Decodes a SONOFF start/end minute structure into two Lua numbers. +local function decode_time_period(value) + -- Both vendor time-period attributes are reported as a two-member structure. + return get_structure_pair_numbers(value) +end + +-- Builds a generic attr handler for simple private attributes. +local function simple_private_attribute_handler(attribute_id) + -- Most TP-WGZBA vendor attributes are simple bool/int/enum values. + return function(driver, device, value, zb_rx) + emit_simple_private_attribute(device, attribute_id, get_attribute_value(value)) + end +end + +-- Emits the split low/high threshold capability values from the vendor structure payload. +local function private_temperature_control_threshold_handler(driver, device, value, zb_rx) + -- The threshold attribute reports both hysteresis values atomically. + local low_threshold, high_threshold = get_structure_pair_numbers(value) + if low_threshold ~= nil and high_threshold ~= nil then + emit_event_if_supported(device, TempCtrlThreshLow, TempCtrlThreshLow.tempCtrlThreshLow({ value = low_threshold / 100.0, unit = UNIT_CELSIUS })) + emit_event_if_supported(device, TempCtrlThreshHigh, TempCtrlThreshHigh.tempCtrlThreshHigh({ value = high_threshold / 100.0, unit = UNIT_CELSIUS })) + return + end + + local raw = get_payload_bytes(value) + if raw == nil or #raw < 8 then + log.warn("Invalid TP-WGZBA temperature control threshold payload") + return + end + + local low_threshold = decode_int16_le(raw, 4) + local high_threshold = decode_int16_le(raw, 7) + if low_threshold == nil or high_threshold == nil then + log.warn("Incomplete TP-WGZBA temperature control threshold payload") + return + end + + emit_event_if_supported(device, TempCtrlThreshLow, TempCtrlThreshLow.tempCtrlThreshLow({ value = low_threshold / 100.0, unit = UNIT_CELSIUS })) + emit_event_if_supported(device, TempCtrlThreshHigh, TempCtrlThreshHigh.tempCtrlThreshHigh({ value = high_threshold / 100.0, unit = UNIT_CELSIUS })) +end + +-- Emits the split relay output type capability values from the vendor bitmap payload. +local function private_relay_output_type_handler(driver, device, value, zb_rx) + -- Relay bits 0 and 1 encode heating and boiler contact polarity. + local raw_value = get_attribute_value(value) + if type(raw_value) ~= "number" then + return + end + + local relay_one = (raw_value % 2) == 1 and RELAY_OUTPUT_TYPE_NORMALLY_CLOSED or RELAY_OUTPUT_TYPE_NORMALLY_OPEN + local relay_two = (math.floor(raw_value / 2) % 2) == 1 and RELAY_OUTPUT_TYPE_NORMALLY_CLOSED or RELAY_OUTPUT_TYPE_NORMALLY_OPEN + emit_event_if_supported(device, RelayOutTypeR1, RelayOutTypeR1.relayOneOutputType(relay_one)) + emit_event_if_supported(device, RelayOutTypeR2, RelayOutTypeR2.relayTwoOutputType(relay_two)) +end + +-- Emits the start/end minute capability values from the vendor time period structure. +local function emit_time_period_events(device, value, start_capability, end_capability, start_attribute_name, end_attribute_name) + -- Radar DND and screen night mode share the same minute-pair encoding. + local start_minute, end_minute = decode_time_period(value) + if start_minute ~= nil and end_minute ~= nil then + emit_event_if_supported(device, start_capability, start_capability[start_attribute_name]({ value = start_minute, unit = UNIT_MINUTES })) + emit_event_if_supported(device, end_capability, end_capability[end_attribute_name]({ value = end_minute, unit = UNIT_MINUTES })) + return + end + + local raw = get_payload_bytes(value) + if raw == nil or #raw < 8 then + log.warn("Invalid TP-WGZBA time period payload") + return + end + + start_minute = decode_uint16_le(raw, 4) + end_minute = decode_uint16_le(raw, 7) + if start_minute == nil or end_minute == nil then + log.warn("Incomplete TP-WGZBA time period payload") + return + end + + emit_event_if_supported(device, start_capability, start_capability[start_attribute_name]({ value = start_minute, unit = UNIT_MINUTES })) + emit_event_if_supported(device, end_capability, end_capability[end_attribute_name]({ value = end_minute, unit = UNIT_MINUTES })) +end + +-- Stores the decoded radar DND start/end minutes from the vendor structure report. +local function private_radar_do_not_disturb_period_handler(driver, device, value, zb_rx) + -- Keep the reported minute pair visible directly on the main page capability. + emit_time_period_events(device, value, RadarDndStartMin, RadarDndEndMin, "radarDndStartMin", "radarDndEndMin") +end + +-- Stores the decoded screen night start/end minutes from the vendor structure report. +local function private_screen_night_mode_period_handler(driver, device, value, zb_rx) + -- Keep the reported minute pair visible directly on the main page capability. + emit_time_period_events(device, value, ScreenNightStartMin, ScreenNightEndMin, "screenNightStartMin", "screenNightEndMin") +end + +-- Refreshes all standard and selected private attributes used by the first version. +local function do_refresh(self, device) + -- Keep refresh bounded to validated attributes so troubleshooting stays manageable. + device:send(Thermostat.attributes.LocalTemperature:read(device)) + device:send(Thermostat.attributes.OccupiedHeatingSetpoint:read(device)) + device:send(Thermostat.attributes.SystemMode:read(device)) + device:send(Thermostat.attributes.ThermostatRunningState:read(device)) + device:send(Thermostat.attributes.LocalTemperatureCalibration:read(device)) + for _, attribute_id in ipairs(PRIVATE_REFRESH_ATTRIBUTES) do + device:send(read_private_attribute(device, attribute_id)) + end +end + +-- Configures standard thermostat reporting only. +local function do_configure(self, device) + -- Keep the standard bind but do not configure automatic reporting. + device:send(device_management.build_bind_request(device, Thermostat.ID, self.environment_info.hub_zigbee_eui)) +end + +-- Handles initial device join state. +local function device_added(self, device) + -- Seed capability defaults before the first vendor refresh arrives. + emit_supported_modes(device) + emit_main_page_defaults_if_needed(device) + emit_temporary_mode_defaults_if_needed(device) + do_refresh(self, device) +end + +-- Handles driver switch the same way as first add. +local function device_driver_switched(self, device) + -- Driver replacement should refresh state from the device again. + emit_supported_modes(device) + emit_main_page_defaults_if_needed(device) + emit_temporary_mode_defaults_if_needed(device) + do_refresh(self, device) +end + +-- Restores volatile UI state on driver init. +local function device_init(self, device) + -- Override inputs are UI-cached values and still need local defaults before the first interaction. + emit_main_page_defaults_if_needed(device) + emit_temporary_mode_defaults_if_needed(device) +end + +-- Writes the standard thermostat system mode and schedules a readback. +local function set_thermostat_mode(driver, device, command) + -- Write the Zigbee mode directly instead of remapping app semantics. + local mode = THERMOSTAT_MODE_MAP[command.args.mode] + if mode == nil then + return + end + + device:send_to_component(command.component, Thermostat.attributes.SystemMode:write(device, mode)) +end + +-- Creates dedicated thermostat-mode command handlers. +local function thermostat_mode_setter(mode_name) + -- Reuse the common setter so all mode commands share one write path. + return function(driver, device, command) + set_thermostat_mode(driver, device, { component = command.component, args = { mode = mode_name } }) + end +end + +-- Writes the standard heating setpoint and schedules a readback. +local function set_heating_setpoint(driver, device, command) + -- Keep the first version on the standard setpoint attribute only. + local value = command.args.setpoint + if value >= 40 then + value = utils.f_to_c(value) + end + + value = utils.clamp_value(value, 5.0, 30.0) + device:send_to_component(command.component, Thermostat.attributes.OccupiedHeatingSetpoint:write(device, utils.round(value * 100))) +end + +-- Sends the vendor temporary mode command built from the current UI-side override values. +local function send_temporary_mode_command(device, action_value) + -- Exit carries only the action byte, while boost and timer include duration and target temperature. + local duration_minutes = utils.clamp_value( + utils.round(get_latest_numeric_state(device, TemporaryModeDuration, "temporaryModeTimeSet", 30)), + 1, + 1439 + ) + + if action_value == TEMPORARY_MODE_EXIT then + device:send(send_private_cluster_command(device, TEMPORARY_MODE_COMMAND_ID, string.char(action_value))) + return + end + + local target_temperature = 30.0 + if action_value == TEMPORARY_MODE_TIMER then + target_temperature = utils.clamp_value( + get_latest_numeric_state(device, TemporaryModeTemperature, "temporaryModeTempSet", 30.0), + 5.0, + 30.0 + ) + elseif action_value ~= TEMPORARY_MODE_BOOST then + log.warn("Invalid TP-WGZBA temporary mode action value") + return + end + + local payload = string.char(action_value) + .. encode_uint32_le(duration_minutes * 60) + .. encode_int16_le(utils.round(target_temperature * 100)) + device:send(send_private_cluster_command(device, TEMPORARY_MODE_COMMAND_ID, payload)) +end + +-- Updates the locally cached override duration without writing a vendor attribute. +local function set_temporary_mode_duration(driver, device, command) + -- The duration is only consumed when the user later sends boost or timer. + local requested_value = coerce_numeric_value(command.args.temporaryModeTimeSet) + if requested_value == nil then + log.warn("Invalid TP-WGZBA temporary mode duration value") + return + end + + requested_value = utils.clamp_value(utils.round(requested_value), 1, 1439) + emit_event_if_supported(device, TemporaryModeDuration, TemporaryModeDuration.temporaryModeTimeSet({ value = requested_value, unit = UNIT_MINUTES })) +end + +-- Updates the locally cached override target temperature without writing a vendor attribute. +local function set_temporary_mode_temperature(driver, device, command) + -- Timer mode uses this cached value, while boost always forces 30C on-device. + local requested_value = coerce_numeric_value(command.args.temporaryModeTempSet) + if requested_value == nil then + log.warn("Invalid TP-WGZBA temporary mode temperature value") + return + end + + requested_value = utils.clamp_value(requested_value, 5.0, 30.0) + emit_event_if_supported(device, TemporaryModeTemperature, TemporaryModeTemperature.temporaryModeTempSet({ value = requested_value, unit = UNIT_CELSIUS })) +end + +-- Sends the selected override action and then refreshes the reported vendor status. +local function set_temporary_mode_action(driver, device, command) + -- Only exit, boost, and timer are supported by the published override action capability. + local requested_value = command.args.temporaryModeAction + if requested_value == TEMPORARY_MODE_ACTION_EXIT then + send_temporary_mode_command(device, TEMPORARY_MODE_EXIT) + elseif requested_value == TEMPORARY_MODE_ACTION_BOOST then + send_temporary_mode_command(device, TEMPORARY_MODE_BOOST) + emit_event_if_supported(device, TemporaryModeTemperature, TemporaryModeTemperature.temporaryModeTempSet({ value = 30.0, unit = UNIT_CELSIUS })) + elseif requested_value == TEMPORARY_MODE_ACTION_TIMER then + send_temporary_mode_command(device, TEMPORARY_MODE_TIMER) + else + log.warn("Invalid TP-WGZBA temporary mode action capability value") + return + end + + emit_event_if_supported(device, TemporaryModeAction, TemporaryModeAction.temporaryModeAction(requested_value)) +end + +-- Writes the vendor threshold struct after changing the low capability value. +local function set_temp_ctrl_thresh_low(driver, device, command) + -- The current high threshold must be preserved because the vendor attribute is atomic. + local low_value = coerce_numeric_value(command.args.tempCtrlThreshLow) + local high_value = get_latest_numeric_state(device, TempCtrlThreshHigh, "tempCtrlThreshHigh", PRIVATE_TEMP_CTRL_THRESH_HIGH_DEFAULT) + if low_value == nil or not write_temperature_control_threshold_from_capability(device, low_value, high_value) then + log.warn("Invalid TP-WGZBA low threshold capability value") + end +end + +-- Writes the vendor threshold struct after changing the high capability value. +local function set_temp_ctrl_thresh_high(driver, device, command) + -- The current low threshold must be preserved because the vendor attribute is atomic. + local low_value = get_latest_numeric_state(device, TempCtrlThreshLow, "tempCtrlThreshLow", PRIVATE_TEMP_CTRL_THRESH_LOW_DEFAULT) + local high_value = coerce_numeric_value(command.args.tempCtrlThreshHigh) + if high_value == nil or not write_temperature_control_threshold_from_capability(device, low_value, high_value) then + log.warn("Invalid TP-WGZBA high threshold capability value") + end +end + +-- Writes the radar DND struct after changing the start minute capability. +local function set_radar_dnd_start_min(driver, device, command) + -- The end minute must be preserved because both values share one structure write. + local start_minute = coerce_numeric_value(command.args.radarDndStartMin) + local end_minute = get_latest_numeric_state(device, RadarDndEndMin, "radarDndEndMin", 0) + if start_minute == nil or not write_time_period_attribute(device, PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_PERIOD, start_minute, end_minute) then + log.warn("Invalid TP-WGZBA radar DND start minute capability value") + end +end + +-- Writes the radar DND struct after changing the end minute capability. +local function set_radar_dnd_end_min(driver, device, command) + -- The start minute must be preserved because both values share one structure write. + local start_minute = get_latest_numeric_state(device, RadarDndStartMin, "radarDndStartMin", 0) + local end_minute = coerce_numeric_value(command.args.radarDndEndMin) + if end_minute == nil or not write_time_period_attribute(device, PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_PERIOD, start_minute, end_minute) then + log.warn("Invalid TP-WGZBA radar DND end minute capability value") + end +end + +-- Writes the screen night struct after changing the start minute capability. +local function set_screen_night_start_min(driver, device, command) + -- The end minute must be preserved because both values share one structure write. + local start_minute = coerce_numeric_value(command.args.screenNightStartMin) + local end_minute = get_latest_numeric_state(device, ScreenNightEndMin, "screenNightEndMin", 0) + if start_minute == nil or not write_time_period_attribute(device, PRIVATE_ATTR_SCREEN_NIGHT_MODE_PERIOD, start_minute, end_minute) then + log.warn("Invalid TP-WGZBA screen night start minute capability value") + end +end + +-- Writes the screen night struct after changing the end minute capability. +local function set_screen_night_end_min(driver, device, command) + -- The start minute must be preserved because both values share one structure write. + local start_minute = get_latest_numeric_state(device, ScreenNightStartMin, "screenNightStartMin", 0) + local end_minute = coerce_numeric_value(command.args.screenNightEndMin) + if end_minute == nil or not write_time_period_attribute(device, PRIVATE_ATTR_SCREEN_NIGHT_MODE_PERIOD, start_minute, end_minute) then + log.warn("Invalid TP-WGZBA screen night end minute capability value") + end +end + +-- Converts thermostat running-state bitmaps into the standard operating-state capability. +local function thermostat_running_state_handler(driver, device, value, zb_rx) + -- Use the device-reported state directly instead of inferring from temperatures. + if value:is_heat_second_stage_on_set() or value:is_heat_on_set() then + device:emit_event(ThermostatOperatingState.thermostatOperatingState.heating()) + else + device:emit_event(ThermostatOperatingState.thermostatOperatingState.idle()) + end +end + +-- Emits the standard calibration report through the main-page temperature compensation capability. +local function local_temperature_calibration_handler(driver, device, value, zb_rx) + -- LocalTemperatureCalibration is stored in 0.1 C units by the thermostat cluster. + local raw_value = get_attribute_value(value) + local calibration = coerce_numeric_value(raw_value) + if calibration == nil then + log.warn("Invalid TP-WGZBA local temperature calibration attribute value") + return + end + + calibration = utils.clamp_value(calibration / 10.0, -10.0, 10.0) + emit_event_if_supported(device, TemperatureCompensation, TemperatureCompensation.temperatureCompensation({ value = calibration, unit = UNIT_CELSIUS })) +end + +-- Writes main-page temperature compensation to standard local-temperature calibration. +local function set_temperature_compensation(driver, device, command) + -- The app sends Celsius, while Zigbee LocalTemperatureCalibration uses 0.1 C units. + local calibration = coerce_numeric_value(command.args.temperatureCompensation) + if calibration == nil then + log.warn("Invalid TP-WGZBA temperature compensation capability value") + return + end + + calibration = utils.clamp_value(calibration, -10.0, 10.0) + device:send_to_component(command.component, Thermostat.attributes.LocalTemperatureCalibration:write(device, utils.round(calibration * 10))) + emit_event_if_supported(device, TemperatureCompensation, TemperatureCompensation.temperatureCompensation({ value = calibration, unit = UNIT_CELSIUS })) +end + +local sonoff_thermostat = { + NAME = "SONOFF TP-WGZBA Handler", + zigbee_handlers = { + attr = { + [Thermostat.ID] = { + [Thermostat.attributes.ThermostatRunningState.ID] = thermostat_running_state_handler, + [Thermostat.attributes.LocalTemperatureCalibration.ID] = local_temperature_calibration_handler + }, + [SONOFF_PRIVATE_CLUSTER] = { + [PRIVATE_ATTR_CHILD_LOCK] = simple_private_attribute_handler(PRIVATE_ATTR_CHILD_LOCK), + [PRIVATE_ATTR_BT_PAIRING_BROADCAST_REQ] = simple_private_attribute_handler(PRIVATE_ATTR_BT_PAIRING_BROADCAST_REQ), + [PRIVATE_ATTR_OPEN_WINDOW_DETECTION] = simple_private_attribute_handler(PRIVATE_ATTR_OPEN_WINDOW_DETECTION), + [PRIVATE_ATTR_FROST_PROOF_TEMPERATURE] = simple_private_attribute_handler(PRIVATE_ATTR_FROST_PROOF_TEMPERATURE), + [PRIVATE_ATTR_TEMPORARY_MODE_SETTINGS] = private_temporary_mode_settings_handler, + [PRIVATE_ATTR_TEMPERATURE_CONTROL_THRESHOLD] = private_temperature_control_threshold_handler, + [PRIVATE_ATTR_RADAR_SENSITIVITY_LEVEL] = simple_private_attribute_handler(PRIVATE_ATTR_RADAR_SENSITIVITY_LEVEL), + [PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_ENABLE] = simple_private_attribute_handler(PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_ENABLE), + [PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_PERIOD] = private_radar_do_not_disturb_period_handler, + [PRIVATE_ATTR_SCREEN_WORKING_BRIGHTNESS] = simple_private_attribute_handler(PRIVATE_ATTR_SCREEN_WORKING_BRIGHTNESS), + [PRIVATE_ATTR_SCREEN_STANDBY_BRIGHTNESS] = simple_private_attribute_handler(PRIVATE_ATTR_SCREEN_STANDBY_BRIGHTNESS), + [PRIVATE_ATTR_SCREEN_NIGHT_STANDBY_BRIGHTNESS] = simple_private_attribute_handler(PRIVATE_ATTR_SCREEN_NIGHT_STANDBY_BRIGHTNESS), + [PRIVATE_ATTR_SCREEN_NIGHT_MODE_ENABLE] = simple_private_attribute_handler(PRIVATE_ATTR_SCREEN_NIGHT_MODE_ENABLE), + [PRIVATE_ATTR_SCREEN_NIGHT_MODE_PERIOD] = private_screen_night_mode_period_handler, + [PRIVATE_ATTR_RELAY_OUTPUT_TYPE_BITMAP] = private_relay_output_type_handler, + [PRIVATE_ATTR_HVAC_MESSAGE_NOTIFICATION] = private_hvac_message_handler, + [PRIVATE_ATTR_CURRENT_NTC_TEMPERATURE_RAW] = private_ntc_temperature_handler, + [PRIVATE_ATTR_OVERHEAT_PROTECTION_TEMPERATURE] = simple_private_attribute_handler(PRIVATE_ATTR_OVERHEAT_PROTECTION_TEMPERATURE), + [PRIVATE_ATTR_OVERHEAT_PROTECTION_ENABLE] = simple_private_attribute_handler(PRIVATE_ATTR_OVERHEAT_PROTECTION_ENABLE), + [PRIVATE_ATTR_RADAR_ENABLE] = simple_private_attribute_handler(PRIVATE_ATTR_RADAR_ENABLE) + } + } + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh + }, + [ThermostatMode.ID] = { + [ThermostatMode.commands.setThermostatMode.NAME] = set_thermostat_mode, + [ThermostatMode.commands.auto.NAME] = thermostat_mode_setter(ThermostatMode.thermostatMode.auto.NAME), + [ThermostatMode.commands.off.NAME] = thermostat_mode_setter(ThermostatMode.thermostatMode.off.NAME), + [ThermostatMode.commands.heat.NAME] = thermostat_mode_setter(ThermostatMode.thermostatMode.heat.NAME) + }, + [ThermostatHeatingSetpoint.ID] = { + [ThermostatHeatingSetpoint.commands.setHeatingSetpoint.NAME] = set_heating_setpoint + }, + [TemperatureCompensation.ID] = { + ["setTemperatureCompensation"] = set_temperature_compensation + }, + [ChildLock.ID] = { + [ChildLock.commands.setChildLockState.NAME] = function(driver, device, command) + set_simple_private_capability(driver, device, command, PRIVATE_ATTR_CHILD_LOCK) + end + }, + [OpenWindowDetection.ID] = { + [OpenWindowDetection.commands.setOpenWindowDetection.NAME] = function(driver, device, command) + set_simple_private_capability(driver, device, command, PRIVATE_ATTR_OPEN_WINDOW_DETECTION) + end + }, + [BtPairingBroadcastReq.ID] = { + [BtPairingBroadcastReq.commands.setBtPairingBroadcastReq.NAME] = function(driver, device, command) + set_simple_private_capability(driver, device, command, PRIVATE_ATTR_BT_PAIRING_BROADCAST_REQ) + end + }, + [FrostProofTemperature.ID] = { + [FrostProofTemperature.commands.setFrostProofTemperature.NAME] = function(driver, device, command) + set_simple_private_capability(driver, device, command, PRIVATE_ATTR_FROST_PROOF_TEMPERATURE) + end + }, + [TemporaryModeDuration.ID] = { + [TemporaryModeDuration.commands.setTemporaryModeTimeSet.NAME] = set_temporary_mode_duration + }, + [TemporaryModeTemperature.ID] = { + [TemporaryModeTemperature.commands.setTemporaryModeTempSet.NAME] = set_temporary_mode_temperature + }, + [TemporaryModeAction.ID] = { + [TemporaryModeAction.commands.setTemporaryModeAction.NAME] = set_temporary_mode_action + }, + [RadarSensitivityLevel.ID] = { + [RadarSensitivityLevel.commands.setRadarSensitivityLevel.NAME] = function(driver, device, command) + set_simple_private_capability(driver, device, command, PRIVATE_ATTR_RADAR_SENSITIVITY_LEVEL) + end + }, + [RadarDoNotDisturbEnable.ID] = { + [RadarDoNotDisturbEnable.commands.setRadarDoNotDisturbEnable.NAME] = function(driver, device, command) + set_simple_private_capability(driver, device, command, PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_ENABLE) + end + }, + [RadarDndStartMin.ID] = { + [RadarDndStartMin.commands.setRadarDndStartMin.NAME] = set_radar_dnd_start_min + }, + [RadarDndEndMin.ID] = { + [RadarDndEndMin.commands.setRadarDndEndMin.NAME] = set_radar_dnd_end_min + }, + [ScreenWorkingBrightness.ID] = { + [ScreenWorkingBrightness.commands.setScreenWorkingBrightness.NAME] = function(driver, device, command) + set_simple_private_capability(driver, device, command, PRIVATE_ATTR_SCREEN_WORKING_BRIGHTNESS) + end + }, + [ScreenStandbyBrightness.ID] = { + [ScreenStandbyBrightness.commands.setScreenStandbyBrightness.NAME] = function(driver, device, command) + set_simple_private_capability(driver, device, command, PRIVATE_ATTR_SCREEN_STANDBY_BRIGHTNESS) + end + }, + [ScreenNightStandbyBrightness.ID] = { + [ScreenNightStandbyBrightness.commands.setScreenNightStandbyBrightness.NAME] = function(driver, device, command) + set_simple_private_capability(driver, device, command, PRIVATE_ATTR_SCREEN_NIGHT_STANDBY_BRIGHTNESS) + end + }, + [ScreenNightModeEnable.ID] = { + [ScreenNightModeEnable.commands.setScreenNightModeEnable.NAME] = function(driver, device, command) + set_simple_private_capability(driver, device, command, PRIVATE_ATTR_SCREEN_NIGHT_MODE_ENABLE) + end + }, + [ScreenNightStartMin.ID] = { + [ScreenNightStartMin.commands.setScreenNightStartMin.NAME] = set_screen_night_start_min + }, + [ScreenNightEndMin.ID] = { + [ScreenNightEndMin.commands.setScreenNightEndMin.NAME] = set_screen_night_end_min + }, + [RelayOutTypeR1.ID] = { + [RelayOutTypeR1.commands.setRelayOneOutputType.NAME] = function(driver, device, command) + local relay1_value = command.args.relayOneOutputType + local relay2_value = device:get_latest_state("main", RelayOutTypeR2.ID, "relayTwoOutputType", RELAY_OUTPUT_TYPE_NORMALLY_OPEN) + if not write_relay_output_type(device, relay1_value, relay2_value) then + log.warn("Invalid TP-WGZBA relay 1 output type capability value") + end + end + }, + [RelayOutTypeR2.ID] = { + [RelayOutTypeR2.commands.setRelayTwoOutputType.NAME] = function(driver, device, command) + local relay1_value = device:get_latest_state("main", RelayOutTypeR1.ID, "relayOneOutputType", RELAY_OUTPUT_TYPE_NORMALLY_OPEN) + local relay2_value = command.args.relayTwoOutputType + if not write_relay_output_type(device, relay1_value, relay2_value) then + log.warn("Invalid TP-WGZBA relay 2 output type capability value") + end + end + }, + [TempCtrlThreshLow.ID] = { + [TempCtrlThreshLow.commands.setTempCtrlThreshLow.NAME] = set_temp_ctrl_thresh_low + }, + [TempCtrlThreshHigh.ID] = { + [TempCtrlThreshHigh.commands.setTempCtrlThreshHigh.NAME] = set_temp_ctrl_thresh_high + }, + [OverheatProtectionTemperature.ID] = { + [OverheatProtectionTemperature.commands.setOverheatProtectionTemperature.NAME] = function(driver, device, command) + set_simple_private_capability(driver, device, command, PRIVATE_ATTR_OVERHEAT_PROTECTION_TEMPERATURE) + end + }, + [OverheatProtectionEnable.ID] = { + [OverheatProtectionEnable.commands.setOverheatProtectionEnable.NAME] = function(driver, device, command) + set_simple_private_capability(driver, device, command, PRIVATE_ATTR_OVERHEAT_PROTECTION_ENABLE) + end + }, + [RadarEnable.ID] = { + [RadarEnable.commands.setRadarEnable.NAME] = function(driver, device, command) + set_simple_private_capability(driver, device, command, PRIVATE_ATTR_RADAR_ENABLE) + end + } + }, + lifecycle_handlers = { + added = device_added, + init = device_init, + driverSwitched = device_driver_switched, + doConfigure = do_configure + }, + can_handle = require("sonoff.tp_wgzba.can_handle") +} + +return sonoff_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua b/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua index 3b535c3cf0..542020a390 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/sub_drivers.lua @@ -16,5 +16,6 @@ local sub_drivers = { lazy_load_if_possible("resideo_korea"), lazy_load_if_possible("aqara"), lazy_load_if_possible("wallhero"), + lazy_load_if_possible("sonoff"), } 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..c9f55004a4 --- /dev/null +++ b/drivers/SmartThings/zigbee-thermostat/src/test/test_sonoff_tp_wgzba_thermostat.lua @@ -0,0 +1,903 @@ +-- Copyright 2026 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +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 zcl_messages = require "st.zigbee.zcl" +local messages = require "st.zigbee.messages" +local zb_const = require "st.zigbee.constants" + +local Thermostat = clusters.Thermostat +local ThermostatSystemMode = Thermostat.attributes.SystemMode + +local TemperatureCompensation = capabilities["samplereturn62595.temperatureCompensation"] +local ChildLock = capabilities["drumborder00833.childLockState"] +local OpenWindowDetection = capabilities["drumborder00833.openWindowDetection"] +local BtPairingBroadcastReq = capabilities["drumborder00833.bluetoothPairingBroadcastRequest"] +local FrostProofTemperature = capabilities["drumborder00833.frostProofTemperature"] +local OpenWindowState = capabilities["drumborder00833.openWindowState"] +local NtcTemperature = capabilities["drumborder00833.ntcTemperature"] +local TemporaryModeStatus = capabilities["drumborder00833.overrideStatus"] +local TemporaryModeDuration = capabilities["drumborder00833.overrideModeDuration"] +local TemporaryModeTemperature = capabilities["drumborder00833.overrideModeTemperature"] +local TemporaryModeAction = capabilities["drumborder00833.overrideAction"] +local TempCtrlThreshLow = capabilities["drumborder00833.temperatureControlThresholdLow"] +local TempCtrlThreshHigh = capabilities["drumborder00833.temperatureControlThresholdHigh"] +local RadarSensitivityLevel = capabilities["drumborder00833.radarSensitivityLevel"] +local RadarDoNotDisturbEnable = capabilities["drumborder00833.radarDoNotDisturbEnable"] +local RadarDndStartMin = capabilities["drumborder00833.radarDoNotDisturbStartMinute"] +local RadarDndEndMin = capabilities["drumborder00833.radarDoNotDisturbEndMinute"] +local ScreenWorkingBrightness = capabilities["drumborder00833.screenWorkingBrightness"] +local ScreenStandbyBrightness = capabilities["drumborder00833.screenStandbyBrightness"] +local ScreenNightStandbyBrightness = capabilities["drumborder00833.screenNightStandbyBrightness"] +local ScreenNightModeEnable = capabilities["drumborder00833.screenNightModeEnable"] +local ScreenNightStartMin = capabilities["drumborder00833.screenNightStartMinute"] +local ScreenNightEndMin = capabilities["drumborder00833.screenNightEndMinute"] +local RelayOutTypeR1 = capabilities["drumborder00833.relayHeatingContactType"] +local RelayOutTypeR2 = capabilities["drumborder00833.relayBoilerContactType"] +local OverheatProtectionTemperature = capabilities["drumborder00833.overheatProtectionTemperature"] +local OverheatProtectionEnable = capabilities["drumborder00833.overheatProtectionEnable"] +local RadarEnable = capabilities["drumborder00833.radarEnable"] + +local MFG_CODE = 0x1286 +local SONOFF_PRIVATE_CLUSTER = 0xFC11 +local TEMPORARY_MODE_COMMAND_ID = 0x11 + +local CHILD_LOCK_ATTR = 0x0000 +local BT_PAIRING_BROADCAST_REQ_ATTR = 0x0029 +local OPEN_WINDOW_DETECTION_ATTR = 0x6000 +local FROST_PROOF_TEMPERATURE_ATTR = 0x6002 +local TEMPORARY_MODE_SETTINGS_ATTR = 0x6014 +local TEMPERATURE_CONTROL_THRESHOLD_ATTR = 0x601F +local RADAR_SENSITIVITY_LEVEL_ATTR = 0x6020 +local RADAR_DO_NOT_DISTURB_ENABLE_ATTR = 0x6021 +local RADAR_DO_NOT_DISTURB_PERIOD_ATTR = 0x6022 +local SCREEN_WORKING_BRIGHTNESS_ATTR = 0x6023 +local SCREEN_STANDBY_BRIGHTNESS_ATTR = 0x6024 +local SCREEN_NIGHT_STANDBY_BRIGHTNESS_ATTR = 0x6025 +local SCREEN_NIGHT_MODE_ENABLE_ATTR = 0x6026 +local SCREEN_NIGHT_MODE_PERIOD_ATTR = 0x6027 +local RELAY_OUTPUT_TYPE_BITMAP_ATTR = 0x6028 +local HVAC_MESSAGE_NOTIFICATION_ATTR = 0x6030 +local CURRENT_NTC_TEMPERATURE_RAW_ATTR = 0x6031 +local OVERHEAT_PROTECTION_TEMPERATURE_ATTR = 0x6032 +local OVERHEAT_PROTECTION_ENABLE_ATTR = 0x6034 +local RADAR_ENABLE_ATTR = 0x6035 + +-- Keep in sync with PRIVATE_REFRESH_ATTRIBUTES in sonoff/tp_wgzba/init.lua. +local PRIVATE_REFRESH_ATTRIBUTES = { + CHILD_LOCK_ATTR, + BT_PAIRING_BROADCAST_REQ_ATTR, + OPEN_WINDOW_DETECTION_ATTR, + FROST_PROOF_TEMPERATURE_ATTR, + TEMPORARY_MODE_SETTINGS_ATTR, + RADAR_SENSITIVITY_LEVEL_ATTR, + RADAR_DO_NOT_DISTURB_ENABLE_ATTR, + RADAR_DO_NOT_DISTURB_PERIOD_ATTR, + SCREEN_WORKING_BRIGHTNESS_ATTR, + SCREEN_STANDBY_BRIGHTNESS_ATTR, + SCREEN_NIGHT_STANDBY_BRIGHTNESS_ATTR, + SCREEN_NIGHT_MODE_ENABLE_ATTR, + SCREEN_NIGHT_MODE_PERIOD_ATTR, + RELAY_OUTPUT_TYPE_BITMAP_ATTR, + HVAC_MESSAGE_NOTIFICATION_ATTR, + CURRENT_NTC_TEMPERATURE_RAW_ATTR, + OVERHEAT_PROTECTION_TEMPERATURE_ATTR, + OVERHEAT_PROTECTION_ENABLE_ATTR, + RADAR_ENABLE_ATTR +} + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("thermostat-sonoff.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "SONOFF", + model = "TP-WGZBA", + server_clusters = { 0x0001, 0x0201, 0xFC11 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function expect_main_and_temporary_defaults() + test.socket.capability:__expect_send(mock_device:generate_test_message("main", OpenWindowState.openWindowState("closed"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", NtcTemperature.ntcTemperature("Not Connected"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", TemperatureCompensation.temperatureCompensation({ value = 0.0, unit = "℃" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", ChildLock.childLockState("disabled"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", BtPairingBroadcastReq.btPairingBroadcastReq("disabled"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", OpenWindowDetection.openWindowDetection("disabled"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", FrostProofTemperature.frostProofTemperature({ value = 5.0, unit = "℃" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", RadarSensitivityLevel.radarSensitivityLevel("high"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", RadarDoNotDisturbEnable.radarDoNotDisturbEnable("disabled"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", ScreenWorkingBrightness.screenWorkingBrightness({ value = 5, unit = "level" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", ScreenStandbyBrightness.screenStandbyBrightness({ value = 0, unit = "level" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", ScreenNightStandbyBrightness.screenNightStandbyBrightness({ value = 0, unit = "level" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", ScreenNightModeEnable.screenNightModeEnable("disabled"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", OverheatProtectionTemperature.overheatProtectionTemperature({ value = 33.5, unit = "℃" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", OverheatProtectionEnable.overheatProtectionEnable("disabled"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", RadarEnable.radarEnable("disabled"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", TempCtrlThreshLow.tempCtrlThreshLow({ value = -2.0, unit = "℃" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", TempCtrlThreshHigh.tempCtrlThreshHigh({ value = 2.0, unit = "℃" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", RadarDndStartMin.radarDndStartMin({ value = 0, unit = "min" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", RadarDndEndMin.radarDndEndMin({ value = 0, unit = "min" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", ScreenNightStartMin.screenNightStartMin({ value = 0, unit = "min" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", ScreenNightEndMin.screenNightEndMin({ value = 0, unit = "min" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", RelayOutTypeR1.relayOneOutputType("normallyOpen"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", RelayOutTypeR2.relayTwoOutputType("normallyOpen"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", TemporaryModeStatus.temporaryTemperatureModeSettings("None"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", TemporaryModeDuration.temporaryModeTimeSet({ value = 30, unit = "min" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", TemporaryModeTemperature.temporaryModeTempSet({ value = 30.0, unit = "℃" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", TemporaryModeAction.temporaryModeAction("exit"))) +end + +local function test_init() + test.mock_device.add_test_device(mock_device) + zigbee_test_utils.init_noop_health_check_timer() + -- Driver startup always delivers lifecycle init, which seeds UI defaults. + test.socket.capability:__set_channel_ordering("relaxed") + expect_main_and_temporary_defaults() +end + +test.set_test_init_function(test_init) + +local function build_cluster_specific_command(cluster, command_id, payload) + local zclh = zcl_messages.ZclHeader({ + cmd = data_types.ZCLCommandId(command_id) + }) + zclh.frame_ctrl:set_cluster_specific() + local addrh = messages.AddressHeader( + zb_const.HUB.ADDR, + zb_const.HUB.ENDPOINT, + mock_device:get_short_address(), + mock_device:get_endpoint(cluster), + zb_const.HA_PROFILE_ID, + cluster + ) + return messages.ZigbeeMessageTx({ + address_header = addrh, + body = zcl_messages.ZclMessageBody({ + zcl_header = zclh, + zcl_body = generic_body.GenericBody(payload) + }) + }) +end + +local function expect_refresh_reads() + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.LocalTemperature:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.SystemMode:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.ThermostatRunningState:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.LocalTemperatureCalibration:read(mock_device) }) + for _, attribute_id in ipairs(PRIVATE_REFRESH_ATTRIBUTES) do + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, attribute_id, MFG_CODE) + }) + end +end + +test.register_coroutine_test( + "Device added should emit supported modes and refresh after init already seeded defaults", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.thermostatMode.supportedThermostatModes( + { "off", "auto", "heat" }, + { visibility = { displayed = false } } + ) + ) + ) + expect_refresh_reads() + end +) + +test.register_coroutine_test( + "Driver switched should emit supported modes and refresh after init already seeded defaults", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "driverSwitched" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.thermostatMode.supportedThermostatModes( + { "off", "auto", "heat" }, + { visibility = { displayed = false } } + ) + ) + ) + expect_refresh_reads() + end +) + +test.register_coroutine_test( + "Init should not re-emit defaults when capability state is already seeded", + function() + -- Startup init already populated state in test_init; a second init must be a no-op. + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + end +) + +test.register_message_test( + "Temperature reports using the thermostat cluster should be handled", + { + { + 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" })) + } + } +) + +test.register_message_test( + "Heating setpoint reports should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:build_test_attr_report(mock_device, 2100) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 21.0, unit = "C" })) + } + } +) + +test.register_message_test( + "Running state reports using the thermostat cluster should be handled", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Thermostat.attributes.ThermostatRunningState:build_test_attr_report(mock_device, 1) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState("heating")) + } + } +) + +test.register_coroutine_test( + "Local temperature calibration reports should emit temperature compensation", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + Thermostat.attributes.LocalTemperatureCalibration:build_test_attr_report(mock_device, -15) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", TemperatureCompensation.temperatureCompensation({ value = -1.5, unit = "℃" })) + ) + end +) + +test.register_coroutine_test( + "Temporary mode state reports should emit none/boost/timer from vendor attr 0x6014", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, SONOFF_PRIVATE_CLUSTER, { + { TEMPORARY_MODE_SETTINGS_ATTR, data_types.Uint8.ID, 0xFF } + }, MFG_CODE) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", TemporaryModeStatus.temporaryTemperatureModeSettings("None")) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, SONOFF_PRIVATE_CLUSTER, { + { TEMPORARY_MODE_SETTINGS_ATTR, data_types.Uint8.ID, 0x00 } + }, MFG_CODE) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", TemporaryModeStatus.temporaryTemperatureModeSettings("Boost")) + ) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, SONOFF_PRIVATE_CLUSTER, { + { TEMPORARY_MODE_SETTINGS_ATTR, data_types.Uint8.ID, 0x01 } + }, MFG_CODE) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", TemporaryModeStatus.temporaryTemperatureModeSettings("Timer")) + ) + end +) + +test.register_coroutine_test( + "Child lock reports and capability writes should map through private attr 0x0000", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, SONOFF_PRIVATE_CLUSTER, { + { CHILD_LOCK_ATTR, data_types.Boolean.ID, true } + }, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", ChildLock.childLockState("enabled"))) + + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = ChildLock.ID, component = "main", command = "setChildLockState", args = { "disabled" } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, CHILD_LOCK_ATTR, MFG_CODE, data_types.Boolean, false) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", ChildLock.childLockState("disabled"))) + end +) + +test.register_coroutine_test( + "Setting thermostat mode should write SystemMode without readback", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatMode", command = "auto", args = {}, component = "main" } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.SystemMode:write(mock_device, ThermostatSystemMode.AUTO) + }) + + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatMode", command = "heat", args = {}, component = "main" } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.SystemMode:write(mock_device, ThermostatSystemMode.HEAT) + }) + + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatMode", command = "off", args = {}, component = "main" } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.SystemMode:write(mock_device, ThermostatSystemMode.OFF) + }) + end +) + +test.register_coroutine_test( + "Setting heating setpoint should write OccupiedHeatingSetpoint", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", named_args = { setpoint = 22 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 2200) + }) + end +) + +test.register_coroutine_test( + "Setting heating setpoint should clamp values below the supported range", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", named_args = { setpoint = 4 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 500) + }) + end +) + +test.register_coroutine_test( + "Setting heating setpoint should clamp values above the supported range", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", named_args = { setpoint = 31 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 3000) + }) + end +) + +test.register_coroutine_test( + "Temperature compensation capability should write LocalTemperatureCalibration", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = TemperatureCompensation.ID, + component = "main", + command = "setTemperatureCompensation", + args = { -1.5 } + } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + Thermostat.attributes.LocalTemperatureCalibration:write(mock_device, -15) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", TemperatureCompensation.temperatureCompensation({ value = -1.5, unit = "℃" })) + ) + end +) + +test.register_message_test( + "Refresh should read standard thermostat attrs and selected private attrs", + { + { + 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) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, Thermostat.attributes.LocalTemperatureCalibration:read(mock_device) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, CHILD_LOCK_ATTR, MFG_CODE) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, BT_PAIRING_BROADCAST_REQ_ATTR, MFG_CODE) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, OPEN_WINDOW_DETECTION_ATTR, MFG_CODE) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, FROST_PROOF_TEMPERATURE_ATTR, MFG_CODE) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, TEMPORARY_MODE_SETTINGS_ATTR, MFG_CODE) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, RADAR_SENSITIVITY_LEVEL_ATTR, MFG_CODE) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, RADAR_DO_NOT_DISTURB_ENABLE_ATTR, MFG_CODE) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, RADAR_DO_NOT_DISTURB_PERIOD_ATTR, MFG_CODE) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, SCREEN_WORKING_BRIGHTNESS_ATTR, MFG_CODE) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, SCREEN_STANDBY_BRIGHTNESS_ATTR, MFG_CODE) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, SCREEN_NIGHT_STANDBY_BRIGHTNESS_ATTR, MFG_CODE) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, SCREEN_NIGHT_MODE_ENABLE_ATTR, MFG_CODE) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, SCREEN_NIGHT_MODE_PERIOD_ATTR, MFG_CODE) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, RELAY_OUTPUT_TYPE_BITMAP_ATTR, MFG_CODE) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, HVAC_MESSAGE_NOTIFICATION_ATTR, MFG_CODE) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, CURRENT_NTC_TEMPERATURE_RAW_ATTR, MFG_CODE) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, OVERHEAT_PROTECTION_TEMPERATURE_ATTR, MFG_CODE) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, OVERHEAT_PROTECTION_ENABLE_ATTR, MFG_CODE) } + }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, RADAR_ENABLE_ATTR, MFG_CODE) } + } + } +) + +test.register_coroutine_test( + "Configure should bind Thermostat cluster without configuring reports", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Thermostat.ID) + }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +test.register_coroutine_test( + "Simple private capability writes should update vendor attributes and emit events", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = OpenWindowDetection.ID, component = "main", command = "setOpenWindowDetection", args = { "enabled" } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, OPEN_WINDOW_DETECTION_ATTR, MFG_CODE, data_types.Boolean, true) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", OpenWindowDetection.openWindowDetection("enabled"))) + + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = FrostProofTemperature.ID, component = "main", command = "setFrostProofTemperature", args = { 7.5 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, FROST_PROOF_TEMPERATURE_ATTR, MFG_CODE, data_types.Int16, 750) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", FrostProofTemperature.frostProofTemperature({ value = 7.5, unit = "℃" }))) + + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = RadarEnable.ID, component = "main", command = "setRadarEnable", args = { "enabled" } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, RADAR_ENABLE_ATTR, MFG_CODE, data_types.Boolean, true) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", RadarEnable.radarEnable("enabled"))) + + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = RadarSensitivityLevel.ID, component = "main", command = "setRadarSensitivityLevel", args = { "high" } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, RADAR_SENSITIVITY_LEVEL_ATTR, MFG_CODE, data_types.Uint8, 2) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", RadarSensitivityLevel.radarSensitivityLevel("high"))) + end +) + +test.register_coroutine_test( + "Threshold and time-period capability writes should rewrite vendor structures", + function() + test.socket.capability:__set_channel_ordering("relaxed") + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = TempCtrlThreshHigh.ID, component = "main", command = "setTempCtrlThreshHigh", args = { 1.8 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute( + mock_device, + SONOFF_PRIVATE_CLUSTER, + TEMPERATURE_CONTROL_THRESHOLD_ATTR, + MFG_CODE, + data_types.Structure, + data_types.Structure({ + data_types.Int16(-200), + data_types.Int16(180) + }) + ) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", TempCtrlThreshLow.tempCtrlThreshLow({ value = -2.0, unit = "℃" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", TempCtrlThreshHigh.tempCtrlThreshHigh({ value = 1.8, unit = "℃" }))) + + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = RadarDndStartMin.ID, component = "main", command = "setRadarDndStartMin", args = { 65 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute( + mock_device, + SONOFF_PRIVATE_CLUSTER, + RADAR_DO_NOT_DISTURB_PERIOD_ATTR, + MFG_CODE, + data_types.Structure, + data_types.Structure({ + data_types.Uint16(60), + data_types.Uint16(0) + }) + ) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", RadarDndStartMin.radarDndStartMin({ value = 60, unit = "min" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", RadarDndEndMin.radarDndEndMin({ value = 0, unit = "min" }))) + + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = ScreenNightEndMin.ID, component = "main", command = "setScreenNightEndMin", args = { 1410 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute( + mock_device, + SONOFF_PRIVATE_CLUSTER, + SCREEN_NIGHT_MODE_PERIOD_ATTR, + MFG_CODE, + data_types.Structure, + data_types.Structure({ + data_types.Uint16(0), + data_types.Uint16(1410) + }) + ) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", ScreenNightStartMin.screenNightStartMin({ value = 0, unit = "min" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", ScreenNightEndMin.screenNightEndMin({ value = 1410, unit = "min" }))) + end +) + +test.register_coroutine_test( + "Relay capability writes should rewrite the vendor bitmap with Bitmap8", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = RelayOutTypeR1.ID, component = "main", command = "setRelayOneOutputType", args = { "normallyClosed" } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, RELAY_OUTPUT_TYPE_BITMAP_ATTR, MFG_CODE, data_types.Bitmap8, 1) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", RelayOutTypeR1.relayOneOutputType("normallyClosed"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", RelayOutTypeR2.relayTwoOutputType("normallyOpen"))) + end +) + +test.register_coroutine_test( + "Temporary mode duration and temperature only update local capability state", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = TemporaryModeDuration.ID, component = "main", command = "setTemporaryModeTimeSet", args = { 45 } } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", TemporaryModeDuration.temporaryModeTimeSet({ value = 45, unit = "min" })) + ) + + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = TemporaryModeTemperature.ID, component = "main", command = "setTemporaryModeTempSet", args = { 28.5 } } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", TemporaryModeTemperature.temporaryModeTempSet({ value = 28.5, unit = "℃" })) + ) + end +) + +test.register_coroutine_test( + "Temporary mode exit sends vendor command then emits action state", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = TemporaryModeAction.ID, component = "main", command = "setTemporaryModeAction", args = { "exit" } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + build_cluster_specific_command(SONOFF_PRIVATE_CLUSTER, TEMPORARY_MODE_COMMAND_ID, "\x00") + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", TemporaryModeAction.temporaryModeAction("exit"))) + end +) + +test.register_coroutine_test( + "Temporary mode boost forces 30C UI temperature and sends vendor payload", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = TemporaryModeDuration.ID, component = "main", command = "setTemporaryModeTimeSet", args = { 45 } } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", TemporaryModeDuration.temporaryModeTimeSet({ value = 45, unit = "min" })) + ) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = TemporaryModeAction.ID, component = "main", command = "setTemporaryModeAction", args = { "boost" } } + }) + -- action, duration seconds LE (45*60=2700 -> 8C 0A 00 00), temperature 30.00C -> B8 0B + test.socket.zigbee:__expect_send({ + mock_device.id, + build_cluster_specific_command(SONOFF_PRIVATE_CLUSTER, TEMPORARY_MODE_COMMAND_ID, "\x01\x8C\x0A\x00\x00\xB8\x0B") + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", TemporaryModeTemperature.temporaryModeTempSet({ value = 30.0, unit = "℃" })) + ) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", TemporaryModeAction.temporaryModeAction("boost"))) + end +) + +test.register_coroutine_test( + "Temporary mode timer sends vendor payload built from cached duration and temperature", + function() + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = TemporaryModeDuration.ID, component = "main", command = "setTemporaryModeTimeSet", args = { 45 } } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", TemporaryModeDuration.temporaryModeTimeSet({ value = 45, unit = "min" })) + ) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = TemporaryModeTemperature.ID, component = "main", command = "setTemporaryModeTempSet", args = { 28.5 } } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", TemporaryModeTemperature.temporaryModeTempSet({ value = 28.5, unit = "℃" })) + ) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = TemporaryModeAction.ID, component = "main", command = "setTemporaryModeAction", args = { "timer" } } + }) + -- action=2, duration 2700s, temperature 28.50C -> 22 0B + test.socket.zigbee:__expect_send({ + mock_device.id, + build_cluster_specific_command(SONOFF_PRIVATE_CLUSTER, TEMPORARY_MODE_COMMAND_ID, "\x02\x8C\x0A\x00\x00\x22\x0B") + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", TemporaryModeAction.temporaryModeAction("timer"))) + end +) + +test.register_coroutine_test( + "HVAC message and NTC private reports should update read-only capabilities", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, SONOFF_PRIVATE_CLUSTER, { + { HVAC_MESSAGE_NOTIFICATION_ATTR, data_types.OctetString.ID, "\x00\x01\x01" } + }, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", OpenWindowState.openWindowState("open"))) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, SONOFF_PRIVATE_CLUSTER, { + { CURRENT_NTC_TEMPERATURE_RAW_ATTR, data_types.Int16.ID, 2350 } + }, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", NtcTemperature.ntcTemperature("23.50 C"))) + + test.wait_for_events() + + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, SONOFF_PRIVATE_CLUSTER, { + { CURRENT_NTC_TEMPERATURE_RAW_ATTR, data_types.Int16.ID, -32768 } + }, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", NtcTemperature.ntcTemperature("Not Connected"))) + end +) + +test.register_coroutine_test( + "Relay bitmap reports should split into heating and boiler contact capabilities", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, SONOFF_PRIVATE_CLUSTER, { + { RELAY_OUTPUT_TYPE_BITMAP_ATTR, data_types.Bitmap8.ID, 0x03 } + }, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", RelayOutTypeR1.relayOneOutputType("normallyClosed"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", RelayOutTypeR2.relayTwoOutputType("normallyClosed"))) + end +) + +test.run_registered_tests() From 6f2bf50533b059db44590d554f4f589f8489cebe Mon Sep 17 00:00:00 2001 From: Jiyon Yu Date: Mon, 21 Sep 2026 18:09:01 +0800 Subject: [PATCH 2/8] Fix TP-WGZBA heating setpoint tests for API 57 compatibility --- .../src/test/test_sonoff_tp_wgzba_thermostat.lua | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 index c9f55004a4..9fe72859a8 100644 --- 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 @@ -405,7 +405,8 @@ test.register_coroutine_test( function() test.socket.capability:__queue_receive({ mock_device.id, - { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", named_args = { setpoint = 22 } } + -- positional for API 57; named_args for newer libs with empty reconstructed arg_lists + { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", args = { 22 }, named_args = { setpoint = 22 } } }) test.socket.zigbee:__expect_send({ mock_device.id, @@ -419,7 +420,7 @@ test.register_coroutine_test( function() test.socket.capability:__queue_receive({ mock_device.id, - { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", named_args = { setpoint = 4 } } + { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", args = { 4 }, named_args = { setpoint = 4 } } }) test.socket.zigbee:__expect_send({ mock_device.id, @@ -433,7 +434,7 @@ test.register_coroutine_test( function() test.socket.capability:__queue_receive({ mock_device.id, - { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", named_args = { setpoint = 31 } } + { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", args = { 31 }, named_args = { setpoint = 31 } } }) test.socket.zigbee:__expect_send({ mock_device.id, From 44e20e9624316ba5b918dda0b8a0485b725b37c8 Mon Sep 17 00:00:00 2001 From: Jiyon Yu Date: Tue, 22 Sep 2026 14:11:13 +0800 Subject: [PATCH 3/8] Fix TP-WGZBA tests without custom capability definitions --- .../src/test/test_sonoff_tp_wgzba_thermostat.lua | 8 ++++++++ 1 file changed, 8 insertions(+) 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 index 9fe72859a8..c4af82f908 100644 --- 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 @@ -56,6 +56,10 @@ local OverheatProtectionTemperature = capabilities["drumborder00833.overheatProt local OverheatProtectionEnable = capabilities["drumborder00833.overheatProtectionEnable"] local RadarEnable = capabilities["drumborder00833.radarEnable"] +local custom_capabilities_available = pcall(function() + OpenWindowState.openWindowState("closed") +end) + local MFG_CODE = 0x1286 local SONOFF_PRIVATE_CLUSTER = 0xFC11 local TEMPORARY_MODE_COMMAND_ID = 0x11 @@ -197,6 +201,8 @@ local function expect_refresh_reads() end end +if custom_capabilities_available then + test.register_coroutine_test( "Device added should emit supported modes and refresh after init already seeded defaults", function() @@ -901,4 +907,6 @@ test.register_coroutine_test( end ) +end + test.run_registered_tests() From 4bce79e05ea21794800d9912fe5e40a14207628f Mon Sep 17 00:00:00 2001 From: Jiyon Yu Date: Tue, 22 Sep 2026 15:05:07 +0800 Subject: [PATCH 4/8] Add TP-WGZBA can_handle test coverage --- .../test/test_sonoff_tp_wgzba_thermostat.lua | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) 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 index c4af82f908..99dab3aa0a 100644 --- 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 @@ -201,8 +201,62 @@ local function expect_refresh_reads() end end +-- Builds a minimal device object for can_handle tests. +local function can_handle_device(manufacturer, model) + -- The can_handle modules only depend on these two accessors. + return { + get_manufacturer = function() + return manufacturer + end, + get_model = function() + return model + end + } +end + +-- Disables the global lifecycle expectations for direct can_handle tests. +local function noop_test_init() + -- These tests do not register a mock device with the driver socket. +end + +test.register_coroutine_test( + "SONOFF can_handle modules should reject non-matching fingerprints", + function() + local aggregate_can_handle = require "sonoff.can_handle" + local tp_wgzba_can_handle = require "sonoff.tp_wgzba.can_handle" + + local aggregate_rejected = aggregate_can_handle({}, nil, can_handle_device("SONOFF", "OTHER")) + local tp_wgzba_rejected = tp_wgzba_can_handle({}, nil, can_handle_device("OTHER", "TP-WGZBA")) + + assert(not aggregate_rejected) + assert(not tp_wgzba_rejected) + end, + { + test_init = noop_test_init + } +) + if custom_capabilities_available then +test.register_coroutine_test( + "SONOFF can_handle modules should match TP-WGZBA", + function() + local aggregate_can_handle = require "sonoff.can_handle" + local tp_wgzba_can_handle = require "sonoff.tp_wgzba.can_handle" + + local aggregate_handled, aggregate_sub_driver = aggregate_can_handle({}, nil, can_handle_device("SONOFF", "TP-WGZBA")) + local tp_wgzba_handled, tp_wgzba_sub_driver = tp_wgzba_can_handle({}, nil, can_handle_device("SONOFF", "TP-WGZBA")) + + assert(aggregate_handled) + assert(aggregate_sub_driver.NAME == "SONOFF Thermostat Handler") + assert(tp_wgzba_handled) + assert(tp_wgzba_sub_driver.NAME == "SONOFF TP-WGZBA Handler") + end, + { + test_init = noop_test_init + } +) + test.register_coroutine_test( "Device added should emit supported modes and refresh after init already seeded defaults", function() From 05f1f6ab314991d0b94651c0bb735b3623c0f74c Mon Sep 17 00:00:00 2001 From: Jiyon Yu Date: Thu, 24 Sep 2026 09:58:13 +0800 Subject: [PATCH 5/8] Fix TP-WGZBA firmware version reporting --- .../src/sonoff/tp_wgzba/init.lua | 17 ++++++++++++++ .../test/test_sonoff_tp_wgzba_thermostat.lua | 23 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/init.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/init.lua index f1068ac47f..305f59d5c0 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/init.lua @@ -15,8 +15,10 @@ local ZigbeeMessages = require "st.zigbee.messages" local ZigbeeZcl = require "st.zigbee.zcl" local log = require "log" +local Basic = clusters.Basic local Thermostat = clusters.Thermostat +local FirmwareUpdate = capabilities.firmwareUpdate local ThermostatHeatingSetpoint = capabilities.thermostatHeatingSetpoint local ThermostatMode = capabilities.thermostatMode local ThermostatOperatingState = capabilities.thermostatOperatingState @@ -1016,11 +1018,23 @@ local function do_refresh(self, device) device:send(Thermostat.attributes.SystemMode:read(device)) device:send(Thermostat.attributes.ThermostatRunningState:read(device)) device:send(Thermostat.attributes.LocalTemperatureCalibration:read(device)) + device:send(Basic.attributes.SWBuildID:read(device)) for _, attribute_id in ipairs(PRIVATE_REFRESH_ATTRIBUTES) do device:send(read_private_attribute(device, attribute_id)) end end +-- Publishes the device-provided Basic Cluster software build identifier as its firmware version. +local function software_build_id_handler(driver, device, value, zb_rx) + -- Ignore malformed reports because the capability requires a non-empty string. + if type(value.value) ~= "string" or value.value == "" then + log.warn("Invalid TP-WGZBA Basic SWBuildID payload") + return + end + + emit_event_if_supported(device, FirmwareUpdate, FirmwareUpdate.currentVersion({ value = value.value })) +end + -- Configures standard thermostat reporting only. local function do_configure(self, device) -- Keep the standard bind but do not configure automatic reporting. @@ -1262,6 +1276,9 @@ local sonoff_thermostat = { NAME = "SONOFF TP-WGZBA Handler", zigbee_handlers = { attr = { + [Basic.ID] = { + [Basic.attributes.SWBuildID.ID] = software_build_id_handler + }, [Thermostat.ID] = { [Thermostat.attributes.ThermostatRunningState.ID] = thermostat_running_state_handler, [Thermostat.attributes.LocalTemperatureCalibration.ID] = local_temperature_calibration_handler 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 index 99dab3aa0a..43478da03b 100644 --- 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 @@ -24,6 +24,7 @@ local zcl_messages = require "st.zigbee.zcl" local messages = require "st.zigbee.messages" local zb_const = require "st.zigbee.constants" +local Basic = clusters.Basic local Thermostat = clusters.Thermostat local ThermostatSystemMode = Thermostat.attributes.SystemMode @@ -193,6 +194,7 @@ local function expect_refresh_reads() test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.SystemMode:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.ThermostatRunningState:read(mock_device) }) test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.LocalTemperatureCalibration:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, Basic.attributes.SWBuildID:read(mock_device) }) for _, attribute_id in ipairs(PRIVATE_REFRESH_ATTRIBUTES) do test.socket.zigbee:__expect_send({ mock_device.id, @@ -347,6 +349,22 @@ test.register_message_test( } ) +test.register_message_test( + "Basic SWBuildID reports should update the firmware version", + { + { + channel = "zigbee", + direction = "receive", + message = { mock_device.id, Basic.attributes.SWBuildID:build_test_attr_report(mock_device, "1.0.4") } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.firmwareUpdate.currentVersion({ value = "1.0.4" })) + } + } +) + test.register_coroutine_test( "Local temperature calibration reports should emit temperature compensation", function() @@ -558,6 +576,11 @@ test.register_message_test( direction = "send", message = { mock_device.id, Thermostat.attributes.LocalTemperatureCalibration:read(mock_device) } }, + { + channel = "zigbee", + direction = "send", + message = { mock_device.id, Basic.attributes.SWBuildID:read(mock_device) } + }, { channel = "zigbee", direction = "send", From a5ce0e6f23f5e24cb5fb580ddef0c44ac761043e Mon Sep 17 00:00:00 2001 From: Jiyon Yu Date: Thu, 24 Sep 2026 10:46:35 +0800 Subject: [PATCH 6/8] Fix TP-WGZBA review findings --- .../profiles/thermostat-sonoff.yml | 292 +++- .../zigbee-thermostat/src/sonoff/init.lua | 274 +++- .../src/sonoff/sub_drivers.lua | 8 - .../src/sonoff/tp_wgzba/can_handle.lua | 12 - .../src/sonoff/tp_wgzba/fingerprints.lua | 6 - .../src/sonoff/tp_wgzba/init.lua | 1446 ----------------- .../test/test_sonoff_tp_wgzba_thermostat.lua | 1027 ++---------- 7 files changed, 602 insertions(+), 2463 deletions(-) delete mode 100644 drivers/SmartThings/zigbee-thermostat/src/sonoff/sub_drivers.lua delete mode 100644 drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/can_handle.lua delete mode 100644 drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/fingerprints.lua delete mode 100644 drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/init.lua diff --git a/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml index 37e1eec619..649556fb54 100644 --- a/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml +++ b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml @@ -8,85 +8,241 @@ components: version: 1 config: values: - - key: "thermostatOperatingState.value" - enabledValues: - - heating - - idle + - key: "thermostatOperatingState.value" + enabledValues: + - heating + - idle - id: thermostatMode version: 1 config: values: - - key: "thermostatMode.value" - enabledValues: - - off - - auto - - heat + - key: "thermostatMode.value" + enabledValues: + - off + - auto + - heat - id: thermostatHeatingSetpoint version: 1 config: values: - - key: "heatingSetpoint.value" - range: [ 5, 30 ] - step: 0.5 - - id: drumborder00833.overrideStatus - version: 1 - - id: drumborder00833.overrideAction - version: 1 - - id: drumborder00833.overrideModeDuration - version: 1 - - id: drumborder00833.overrideModeTemperature - version: 1 - - id: samplereturn62595.temperatureCompensation - version: 1 - - id: drumborder00833.childLockState - version: 1 - - id: drumborder00833.openWindowDetection - version: 1 - - id: drumborder00833.openWindowState - version: 1 - - id: drumborder00833.bluetoothPairingBroadcastRequest - version: 1 - - id: drumborder00833.frostProofTemperature - version: 1 - - id: drumborder00833.temperatureControlThresholdLow - version: 1 - - id: drumborder00833.temperatureControlThresholdHigh - version: 1 - - id: drumborder00833.overheatProtectionEnable - version: 1 - - id: drumborder00833.overheatProtectionTemperature - version: 1 - - id: drumborder00833.screenWorkingBrightness - version: 1 - - id: drumborder00833.screenStandbyBrightness - version: 1 - - id: drumborder00833.screenNightModeEnable - version: 1 - - id: drumborder00833.screenNightStandbyBrightness - version: 1 - - id: drumborder00833.screenNightStartMinute - version: 1 - - id: drumborder00833.screenNightEndMinute - version: 1 - - id: drumborder00833.radarEnable - version: 1 - - id: drumborder00833.radarSensitivityLevel - version: 1 - - id: drumborder00833.radarDoNotDisturbEnable - version: 1 - - id: drumborder00833.radarDoNotDisturbStartMinute - version: 1 - - id: drumborder00833.radarDoNotDisturbEndMinute - version: 1 - - id: drumborder00833.relayHeatingContactType - version: 1 - - id: drumborder00833.relayBoilerContactType - version: 1 - - id: drumborder00833.ntcTemperature - version: 1 + - 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: -5 + maximum: 5 + default: 0 +- name: childLock + title: Child lock + description: Lock the thermostat controls. + required: true + preferenceType: boolean + definition: + default: false +- name: bluetoothPairingBroadcast + 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: screenNightStandbyBrightness + 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: relayHeatingNormallyClosed + title: Heating relay normally closed + description: Configure the heating relay contact type. + required: true + preferenceType: boolean + definition: + default: false +- name: relayBoilerNormallyClosed + title: Boiler relay normally closed + description: Configure the boiler relay contact type. + required: true + preferenceType: boolean + definition: + default: false +- name: overheatProtectionTemperature + 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/init.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua index 2f4fc29262..54c27138f6 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua @@ -1,10 +1,278 @@ -- 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 Basic = clusters.Basic +local Thermostat = clusters.Thermostat +local ThermostatHeatingSetpoint = capabilities.thermostatHeatingSetpoint +local ThermostatMode = capabilities.thermostatMode +local ThermostatOperatingState = capabilities.thermostatOperatingState + +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 THERMOSTAT_MODE_MAP = { + [ThermostatMode.thermostatMode.off.NAME] = Thermostat.attributes.SystemMode.OFF, + [ThermostatMode.thermostatMode.auto.NAME] = Thermostat.attributes.SystemMode.AUTO, + [ThermostatMode.thermostatMode.heat.NAME] = Thermostat.attributes.SystemMode.HEAT, +} + +local PRIVATE_PREFERENCES = { + childLock = { attribute = PRIVATE_ATTR_CHILD_LOCK, data_type = data_types.Boolean }, + bluetoothPairingBroadcast = { 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 }, + screenNightStandbyBrightness = { attribute = PRIVATE_ATTR_SCREEN_NIGHT_STANDBY_BRIGHTNESS, data_type = data_types.Uint8 }, + screenNightMode = { attribute = PRIVATE_ATTR_SCREEN_NIGHT_MODE, data_type = data_types.Boolean }, + overheatProtectionTemperature = { 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 + 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.char(action_byte, duration_seconds % 0x100, math.floor(duration_seconds / 0x100) % 0x100, math.floor(duration_seconds / 0x10000) % 0x100, math.floor(duration_seconds / 0x1000000) % 0x100, temperature_centi % 0x100, math.floor(temperature_centi / 0x100) % 0x100) + local endpoint = device:get_endpoint(SONOFF_PRIVATE_CLUSTER) or device.fingerprinted_endpoint_id or 1 + local header = ZigbeeZcl.ZclHeader({ cmd = data_types.ZCLCommandId(TEMPORARY_MODE_COMMAND_ID) }) + header.frame_ctrl:set_cluster_specific() + device:send(ZigbeeMessages.ZigbeeMessageTx({ + address_header = ZigbeeMessages.AddressHeader(ZigbeeConstants.HUB.ADDR, ZigbeeConstants.HUB.ENDPOINT, device:get_short_address(), endpoint, ZigbeeConstants.HA_PROFILE_ID, SONOFF_PRIVATE_CLUSTER), + body = ZigbeeZcl.ZclMessageBody({ zcl_header = header, zcl_body = generic_body.GenericBody(payload) }), + })) +end + +-- Reads the standard attributes that back the supported SmartThings capabilities. +local function do_refresh(_, device) + -- Private settings are changed through preferences and do not expose custom capability state. + device:send(Thermostat.attributes.LocalTemperature:read(device)) + device:send(Thermostat.attributes.OccupiedHeatingSetpoint:read(device)) + device:send(Thermostat.attributes.SystemMode:read(device)) + device:send(Thermostat.attributes.ThermostatRunningState:read(device)) + device:send(Basic.attributes.SWBuildID:read(device)) +end + +-- Configures reporting for the standard thermostat attributes shown in the app. +local function do_configure(self, device) + -- Binding and reporting keep the UI current without custom-cluster parsing. + device:send(device_management.build_bind_request(device, Thermostat.ID, self.environment_info.hub_zigbee_eui)) + device:send(Thermostat.attributes.LocalTemperature:configure_reporting(device, 10, 300, 10)) + device:send(Thermostat.attributes.OccupiedHeatingSetpoint:configure_reporting(device, 10, 300, 50)) + device:send(Thermostat.attributes.SystemMode:configure_reporting(device, 10, 300)) + device:send(Thermostat.attributes.ThermostatRunningState:configure_reporting(device, 10, 300, 1)) +end + +-- Applies preference changes to the matching standard or manufacturer-specific setting. +local function info_changed(_, device, _, args) + -- Only changed preferences generate Zigbee traffic to avoid rewriting settings on unrelated updates. + local old_preferences = args.old_st_store.preferences or {} + for name, preference in pairs(PRIVATE_PREFERENCES) do + local value = device.preferences[name] + if value ~= nil and old_preferences[name] ~= value then + local encoded_value = preference_value(value, preference) + if encoded_value ~= nil then + device:send(write_private_attribute(device, preference.attribute, preference.data_type, encoded_value)) + end + end + end + if device.preferences.temperatureCompensation ~= old_preferences.temperatureCompensation then + local compensation = tonumber(device.preferences.temperatureCompensation) + if compensation ~= nil then + device:send(Thermostat.attributes.LocalTemperatureCalibration:write(device, utils.round(compensation * 10))) + end + end + if device.preferences.temperatureThresholdLow ~= old_preferences.temperatureThresholdLow or device.preferences.temperatureThresholdHigh ~= old_preferences.temperatureThresholdHigh then + local low, high = tonumber(device.preferences.temperatureThresholdLow), tonumber(device.preferences.temperatureThresholdHigh) + if low ~= nil and high ~= nil then + device:send(write_private_attribute(device, PRIVATE_ATTR_TEMPERATURE_CONTROL_THRESHOLD, data_types.Structure, data_types.Structure({ data_types.Int16(utils.round(low * 100)), data_types.Int16(utils.round(high * 100)) }))) + end + end + if device.preferences.radarDoNotDisturbStart ~= old_preferences.radarDoNotDisturbStart or device.preferences.radarDoNotDisturbEnd ~= old_preferences.radarDoNotDisturbEnd then + write_schedule(device, PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_PERIOD, device.preferences.radarDoNotDisturbStart, device.preferences.radarDoNotDisturbEnd) + end + if device.preferences.screenNightStart ~= old_preferences.screenNightStart or device.preferences.screenNightEnd ~= old_preferences.screenNightEnd then + write_schedule(device, PRIVATE_ATTR_SCREEN_NIGHT_PERIOD, device.preferences.screenNightStart, device.preferences.screenNightEnd) + end + if device.preferences.relayHeatingNormallyClosed ~= old_preferences.relayHeatingNormallyClosed or device.preferences.relayBoilerNormallyClosed ~= old_preferences.relayBoilerNormallyClosed then + local relay_bitmap = (device.preferences.relayHeatingNormallyClosed and 1 or 0) + (device.preferences.relayBoilerNormallyClosed and 2 or 0) + device:send(write_private_attribute(device, PRIVATE_ATTR_RELAY_OUTPUT_TYPE, data_types.Bitmap8, relay_bitmap)) + end + if device.preferences.temporaryModeAction ~= old_preferences.temporaryModeAction then + write_temporary_mode(device) + end +end + +-- Publishes the device software build identifier through the standard firmware capability. +local function software_build_id_handler(_, device, value) + -- The framework decodes the String attribute before this handler is called. + if type(value.value) == "string" and value.value ~= "" then + device:emit_event(capabilities.firmwareUpdate.currentVersion({ value = value.value })) + end +end + +-- Converts the decoded local-temperature attribute from centi-degrees Celsius. +local function local_temperature_handler(_, device, value) + -- Standard ZCL decoding provides the signed Int16 directly in value.value. + device:emit_event(capabilities.temperatureMeasurement.temperature({ value = value.value / 100, unit = "C" })) +end + +-- Converts the decoded occupied-heating-setpoint attribute from centi-degrees Celsius. +local function heating_setpoint_handler(_, device, value) + -- The profile declares a single heating setpoint in Celsius. + device:emit_event(ThermostatHeatingSetpoint.heatingSetpoint({ value = value.value / 100, unit = "C" })) +end + +-- Maps the decoded SystemMode value to the supported SmartThings mode. +local function system_mode_handler(_, device, value) + -- TP-WGZBA only exposes off, auto, and heat through the certified profile. + local mode = ({ [Thermostat.attributes.SystemMode.OFF] = ThermostatMode.thermostatMode.off, [Thermostat.attributes.SystemMode.AUTO] = ThermostatMode.thermostatMode.auto, [Thermostat.attributes.SystemMode.HEAT] = ThermostatMode.thermostatMode.heat })[value.value] + if mode ~= nil then + device:emit_event(mode()) + end +end + +-- Maps the decoded ThermostatRunningState bitmap to heating or idle. +local function running_state_handler(_, device, value) + -- Any active running-state bit is represented as heating for this heating-only thermostat. + device:emit_event(value.value == 0 and ThermostatOperatingState.thermostatOperatingState.idle() or ThermostatOperatingState.thermostatOperatingState.heating()) +end + +-- Writes a requested thermostat mode and reads it back after the device applies the change. +local function set_thermostat_mode(_, device, command) + -- Delayed readback covers devices that do not report SystemMode changes. + local mode = THERMOSTAT_MODE_MAP[command.args.mode] + if mode == nil then + return + end + device:send_to_component(command.component, Thermostat.attributes.SystemMode:write(device, mode)) + device.thread:call_with_delay(2, function() + device:send_to_component(command.component, Thermostat.attributes.SystemMode:read(device)) + end) +end + +-- Writes the requested heating setpoint using the thermostat cluster's centi-degree unit. +local function set_heating_setpoint(_, device, command) + -- The driver accepts Fahrenheit app input and clamps all writes to the profile range. + local value = command.args.setpoint + if value >= 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 Thermostat Handler", - sub_drivers = require("sonoff.sub_drivers"), - can_handle = require("sonoff.can_handle"), + NAME = "SONOFF TP-WGZBA Thermostat Handler", + can_handle = require "sonoff.can_handle", + lifecycle_handlers = { + added = function(driver, device) + -- Publish the fixed mode set before the first standard-attribute refresh. + device:emit_event(ThermostatMode.supportedThermostatModes({ "off", "auto", "heat" }, { visibility = { displayed = false } })) + do_refresh(driver, device) + end, + driverSwitched = function(driver, device) + -- Refresh state after the profile and handler have been replaced. + device:emit_event(ThermostatMode.supportedThermostatModes({ "off", "auto", "heat" }, { visibility = { displayed = false } })) + do_refresh(driver, device) + end, + doConfigure = do_configure, + infoChanged = info_changed, + }, + zigbee_handlers = { + attr = { + [Basic.ID] = { [Basic.attributes.SWBuildID.ID] = software_build_id_handler }, + [Thermostat.ID] = { + [Thermostat.attributes.LocalTemperature.ID] = local_temperature_handler, + [Thermostat.attributes.OccupiedHeatingSetpoint.ID] = heating_setpoint_handler, + [Thermostat.attributes.SystemMode.ID] = system_mode_handler, + [Thermostat.attributes.ThermostatRunningState.ID] = running_state_handler, + }, + }, + }, + capability_handlers = { + [capabilities.refresh.ID] = { [capabilities.refresh.commands.refresh.NAME] = do_refresh }, + [ThermostatMode.ID] = { [ThermostatMode.commands.setThermostatMode.NAME] = set_thermostat_mode }, + [ThermostatHeatingSetpoint.ID] = { [ThermostatHeatingSetpoint.commands.setHeatingSetpoint.NAME] = set_heating_setpoint }, + }, } return sonoff_thermostat diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/sub_drivers.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/sub_drivers.lua deleted file mode 100644 index 29dd212572..0000000000 --- a/drivers/SmartThings/zigbee-thermostat/src/sonoff/sub_drivers.lua +++ /dev/null @@ -1,8 +0,0 @@ --- Copyright 2026 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -local lazy_load_if_possible = require "lazy_load_subdriver" - -return { - lazy_load_if_possible("sonoff.tp_wgzba") -} 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 deleted file mode 100644 index 0a97dad57a..0000000000 --- a/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/can_handle.lua +++ /dev/null @@ -1,12 +0,0 @@ --- 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 deleted file mode 100644 index 664e907947..0000000000 --- a/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/fingerprints.lua +++ /dev/null @@ -1,6 +0,0 @@ --- 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 deleted file mode 100644 index 305f59d5c0..0000000000 --- a/drivers/SmartThings/zigbee-thermostat/src/sonoff/tp_wgzba/init.lua +++ /dev/null @@ -1,1446 +0,0 @@ --- Copyright 2026 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. - -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 log = require "log" - -local Basic = clusters.Basic -local Thermostat = clusters.Thermostat - -local FirmwareUpdate = capabilities.firmwareUpdate -local ThermostatHeatingSetpoint = capabilities.thermostatHeatingSetpoint -local ThermostatMode = capabilities.thermostatMode -local ThermostatOperatingState = capabilities.thermostatOperatingState -local TemperatureCompensation = capabilities["samplereturn62595.temperatureCompensation"] -local ChildLock = capabilities["drumborder00833.childLockState"] -local OpenWindowDetection = capabilities["drumborder00833.openWindowDetection"] -local BtPairingBroadcastReq = capabilities["drumborder00833.bluetoothPairingBroadcastRequest"] -local FrostProofTemperature = capabilities["drumborder00833.frostProofTemperature"] -local OpenWindowState = capabilities["drumborder00833.openWindowState"] -local NtcTemperature = capabilities["drumborder00833.ntcTemperature"] -local TemporaryModeStatus = capabilities["drumborder00833.overrideStatus"] -local TemporaryModeDuration = capabilities["drumborder00833.overrideModeDuration"] -local TemporaryModeTemperature = capabilities["drumborder00833.overrideModeTemperature"] -local TemporaryModeAction = capabilities["drumborder00833.overrideAction"] -local TempCtrlThreshLow = capabilities["drumborder00833.temperatureControlThresholdLow"] -local TempCtrlThreshHigh = capabilities["drumborder00833.temperatureControlThresholdHigh"] -local RadarSensitivityLevel = capabilities["drumborder00833.radarSensitivityLevel"] -local RadarDoNotDisturbEnable = capabilities["drumborder00833.radarDoNotDisturbEnable"] -local RadarDndStartMin = capabilities["drumborder00833.radarDoNotDisturbStartMinute"] -local RadarDndEndMin = capabilities["drumborder00833.radarDoNotDisturbEndMinute"] -local ScreenWorkingBrightness = capabilities["drumborder00833.screenWorkingBrightness"] -local ScreenStandbyBrightness = capabilities["drumborder00833.screenStandbyBrightness"] -local ScreenNightStandbyBrightness = capabilities["drumborder00833.screenNightStandbyBrightness"] -local ScreenNightModeEnable = capabilities["drumborder00833.screenNightModeEnable"] -local ScreenNightStartMin = capabilities["drumborder00833.screenNightStartMinute"] -local ScreenNightEndMin = capabilities["drumborder00833.screenNightEndMinute"] -local RelayOutTypeR1 = capabilities["drumborder00833.relayHeatingContactType"] -local RelayOutTypeR2 = capabilities["drumborder00833.relayBoilerContactType"] -local OverheatProtectionTemperature = capabilities["drumborder00833.overheatProtectionTemperature"] -local OverheatProtectionEnable = capabilities["drumborder00833.overheatProtectionEnable"] -local RadarEnable = capabilities["drumborder00833.radarEnable"] - -local SONOFF_MFG_CODE = 0x1286 -local SONOFF_PRIVATE_CLUSTER = 0xFC11 - -local TEMPORARY_MODE_COMMAND_ID = 0x11 -local TEMPORARY_MODE_EXIT = 0x00 -local TEMPORARY_MODE_BOOST = 0x01 -local TEMPORARY_MODE_TIMER = 0x02 -local TEMPORARY_MODE_ATTR_BOOST = 0x00 -local TEMPORARY_MODE_ATTR_TIMER = 0x01 -local TEMPORARY_MODE_ATTR_NONE = 0xFF - -local PRIVATE_ATTR_CHILD_LOCK = 0x0000 -local PRIVATE_ATTR_BT_PAIRING_BROADCAST_REQ = 0x0029 -local PRIVATE_ATTR_OPEN_WINDOW_DETECTION = 0x6000 -local PRIVATE_ATTR_FROST_PROOF_TEMPERATURE = 0x6002 -local PRIVATE_ATTR_TEMPORARY_MODE_SETTINGS = 0x6014 -local PRIVATE_ATTR_TEMPERATURE_CONTROL_THRESHOLD = 0x601F -local PRIVATE_ATTR_RADAR_SENSITIVITY_LEVEL = 0x6020 -local PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_ENABLE = 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_ENABLE = 0x6026 -local PRIVATE_ATTR_SCREEN_NIGHT_MODE_PERIOD = 0x6027 -local PRIVATE_ATTR_RELAY_OUTPUT_TYPE_BITMAP = 0x6028 -local PRIVATE_ATTR_HVAC_MESSAGE_NOTIFICATION = 0x6030 -local PRIVATE_ATTR_CURRENT_NTC_TEMPERATURE_RAW = 0x6031 -local PRIVATE_ATTR_OVERHEAT_PROTECTION_TEMPERATURE = 0x6032 -local PRIVATE_ATTR_OVERHEAT_PROTECTION_ENABLE = 0x6034 -local PRIVATE_ATTR_RADAR_ENABLE = 0x6035 - -local CAPABILITY_STATE_ENABLED = "enabled" -local CAPABILITY_STATE_DISABLED = "disabled" -local OPEN_WINDOW_STATE_OPEN = "open" -local OPEN_WINDOW_STATE_CLOSED = "closed" -local RADAR_SENSITIVITY_LOW = "low" -local RADAR_SENSITIVITY_MEDIUM = "medium" -local RADAR_SENSITIVITY_HIGH = "high" -local RELAY_OUTPUT_TYPE_NORMALLY_OPEN = "normallyOpen" -local RELAY_OUTPUT_TYPE_NORMALLY_CLOSED = "normallyClosed" -local TEMPORARY_MODE_STATE_NONE = "None" -local TEMPORARY_MODE_STATE_BOOST = "Boost" -local TEMPORARY_MODE_STATE_TIMER = "Timer" -local TEMPORARY_MODE_ACTION_EXIT = "exit" -local TEMPORARY_MODE_ACTION_BOOST = "boost" -local TEMPORARY_MODE_ACTION_TIMER = "timer" -local UNIT_CELSIUS = "℃" -local UNIT_MINUTES = "min" -local UNIT_LEVEL = "level" -local PRIVATE_HVAC_OPEN_WINDOW_TLV = 0x00 -local PRIVATE_HVAC_OPEN_WINDOW_TLV_LENGTH = 0x01 -local PRIVATE_NTC_INVALID_CODE = -32768 -local PRIVATE_NTC_OPEN_CIRCUIT_CODE = -32512 -local PRIVATE_NTC_SHORT_CIRCUIT_CODE = -32256 -local PRIVATE_NTC_ADC_INVALID_CODE = -32000 -local PRIVATE_NTC_MIN_VALID = -27315 -local PRIVATE_TEMP_CTRL_THRESH_LOW_DEFAULT = -2.0 -local PRIVATE_TEMP_CTRL_THRESH_HIGH_DEFAULT = 2.0 -local PRIVATE_TEMP_CTRL_THRESH_LOW_MIN = -2.6 -local PRIVATE_TEMP_CTRL_THRESH_LOW_MAX = -0.2 -local PRIVATE_TEMP_CTRL_THRESH_HIGH_MIN = 0.0 -local PRIVATE_TEMP_CTRL_THRESH_HIGH_MAX = 2.6 -local PRIVATE_TIME_STEP_MINUTES = 30 -local PRIVATE_TIME_MAX_MINUTES = 1439 -local SUPPORTED_MODES = { - ThermostatMode.thermostatMode.off.NAME, - ThermostatMode.thermostatMode.auto.NAME, - ThermostatMode.thermostatMode.heat.NAME -} - -local THERMOSTAT_MODE_MAP = { - [ThermostatMode.thermostatMode.off.NAME] = Thermostat.attributes.SystemMode.OFF, - [ThermostatMode.thermostatMode.auto.NAME] = Thermostat.attributes.SystemMode.AUTO, - [ThermostatMode.thermostatMode.heat.NAME] = Thermostat.attributes.SystemMode.HEAT -} - -local SIMPLE_PRIVATE_ATTRIBUTE_INFO = { - [PRIVATE_ATTR_CHILD_LOCK] = { - capability = ChildLock, - attribute_name = "childLockState", - argument_name = "childLockState", - data_type = data_types.Boolean, - kind = "bool" - }, - [PRIVATE_ATTR_BT_PAIRING_BROADCAST_REQ] = { - capability = BtPairingBroadcastReq, - attribute_name = "btPairingBroadcastReq", - argument_name = "btPairingBroadcastReq", - data_type = data_types.Uint8, - kind = "enum", - enum_values = { - [0] = CAPABILITY_STATE_DISABLED, - [1] = CAPABILITY_STATE_ENABLED - }, - reverse_enum_values = { - [CAPABILITY_STATE_DISABLED] = 0, - [CAPABILITY_STATE_ENABLED] = 1 - } - }, - [PRIVATE_ATTR_OPEN_WINDOW_DETECTION] = { - capability = OpenWindowDetection, - attribute_name = "openWindowDetection", - argument_name = "openWindowDetection", - data_type = data_types.Boolean, - kind = "bool" - }, - [PRIVATE_ATTR_FROST_PROOF_TEMPERATURE] = { - capability = FrostProofTemperature, - attribute_name = "frostProofTemperature", - argument_name = "frostProofTemperature", - data_type = data_types.Int16, - kind = "scaled", - min = 5.0, - max = 15.0, - scale = 100, - unit = UNIT_CELSIUS - }, - [PRIVATE_ATTR_TEMPORARY_MODE_SETTINGS] = { - capability = TemporaryModeStatus, - attribute_name = "temporaryTemperatureModeSettings", - argument_name = "temporaryTemperatureModeSettings", - data_type = data_types.Uint8, - kind = "enum", - enum_values = { - [TEMPORARY_MODE_ATTR_NONE] = TEMPORARY_MODE_STATE_NONE, - [TEMPORARY_MODE_ATTR_BOOST] = TEMPORARY_MODE_STATE_BOOST, - [TEMPORARY_MODE_ATTR_TIMER] = TEMPORARY_MODE_STATE_TIMER - } - }, - [PRIVATE_ATTR_RADAR_SENSITIVITY_LEVEL] = { - capability = RadarSensitivityLevel, - attribute_name = "radarSensitivityLevel", - argument_name = "radarSensitivityLevel", - data_type = data_types.Uint8, - kind = "enum", - enum_values = { - [0] = RADAR_SENSITIVITY_LOW, - [1] = RADAR_SENSITIVITY_MEDIUM, - [2] = RADAR_SENSITIVITY_HIGH - }, - reverse_enum_values = { - [RADAR_SENSITIVITY_LOW] = 0, - [RADAR_SENSITIVITY_MEDIUM] = 1, - [RADAR_SENSITIVITY_HIGH] = 2 - } - }, - [PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_ENABLE] = { - capability = RadarDoNotDisturbEnable, - attribute_name = "radarDoNotDisturbEnable", - argument_name = "radarDoNotDisturbEnable", - data_type = data_types.Boolean, - kind = "bool" - }, - [PRIVATE_ATTR_SCREEN_WORKING_BRIGHTNESS] = { - capability = ScreenWorkingBrightness, - attribute_name = "screenWorkingBrightness", - argument_name = "screenWorkingBrightness", - data_type = data_types.Uint8, - kind = "int", - min = 0, - max = 8, - unit = UNIT_LEVEL - }, - [PRIVATE_ATTR_SCREEN_STANDBY_BRIGHTNESS] = { - capability = ScreenStandbyBrightness, - attribute_name = "screenStandbyBrightness", - argument_name = "screenStandbyBrightness", - data_type = data_types.Uint8, - kind = "int", - min = 0, - max = 8, - unit = UNIT_LEVEL - }, - [PRIVATE_ATTR_SCREEN_NIGHT_STANDBY_BRIGHTNESS] = { - capability = ScreenNightStandbyBrightness, - attribute_name = "screenNightStandbyBrightness", - argument_name = "screenNightStandbyBrightness", - data_type = data_types.Uint8, - kind = "int", - min = 0, - max = 8, - unit = UNIT_LEVEL - }, - [PRIVATE_ATTR_SCREEN_NIGHT_MODE_ENABLE] = { - capability = ScreenNightModeEnable, - attribute_name = "screenNightModeEnable", - argument_name = "screenNightModeEnable", - data_type = data_types.Boolean, - kind = "bool" - }, - [PRIVATE_ATTR_OVERHEAT_PROTECTION_TEMPERATURE] = { - capability = OverheatProtectionTemperature, - attribute_name = "overheatProtectionTemperature", - argument_name = "overheatProtectionTemperature", - data_type = data_types.Int16, - kind = "scaled", - min = 20.0, - max = 50.0, - scale = 100, - unit = UNIT_CELSIUS - }, - [PRIVATE_ATTR_OVERHEAT_PROTECTION_ENABLE] = { - capability = OverheatProtectionEnable, - attribute_name = "overheatProtectionEnable", - argument_name = "overheatProtectionEnable", - data_type = data_types.Boolean, - kind = "bool" - }, - [PRIVATE_ATTR_RADAR_ENABLE] = { - capability = RadarEnable, - attribute_name = "radarEnable", - argument_name = "radarEnable", - data_type = data_types.Boolean, - kind = "bool" - } -} - -local PRIVATE_REFRESH_ATTRIBUTES = { - PRIVATE_ATTR_CHILD_LOCK, - PRIVATE_ATTR_BT_PAIRING_BROADCAST_REQ, - PRIVATE_ATTR_OPEN_WINDOW_DETECTION, - PRIVATE_ATTR_FROST_PROOF_TEMPERATURE, - PRIVATE_ATTR_TEMPORARY_MODE_SETTINGS, - PRIVATE_ATTR_RADAR_SENSITIVITY_LEVEL, - PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_ENABLE, - PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_PERIOD, - PRIVATE_ATTR_SCREEN_WORKING_BRIGHTNESS, - PRIVATE_ATTR_SCREEN_STANDBY_BRIGHTNESS, - PRIVATE_ATTR_SCREEN_NIGHT_STANDBY_BRIGHTNESS, - PRIVATE_ATTR_SCREEN_NIGHT_MODE_ENABLE, - PRIVATE_ATTR_SCREEN_NIGHT_MODE_PERIOD, - PRIVATE_ATTR_RELAY_OUTPUT_TYPE_BITMAP, - PRIVATE_ATTR_HVAC_MESSAGE_NOTIFICATION, - PRIVATE_ATTR_CURRENT_NTC_TEMPERATURE_RAW, - PRIVATE_ATTR_OVERHEAT_PROTECTION_TEMPERATURE, - PRIVATE_ATTR_OVERHEAT_PROTECTION_ENABLE, - PRIVATE_ATTR_RADAR_ENABLE -} - -local INITIAL_SIMPLE_PRIVATE_ATTRIBUTE_DEFAULTS = { - [PRIVATE_ATTR_CHILD_LOCK] = false, - [PRIVATE_ATTR_BT_PAIRING_BROADCAST_REQ] = 0, - [PRIVATE_ATTR_OPEN_WINDOW_DETECTION] = false, - [PRIVATE_ATTR_FROST_PROOF_TEMPERATURE] = 500, - [PRIVATE_ATTR_RADAR_SENSITIVITY_LEVEL] = 2, - [PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_ENABLE] = false, - [PRIVATE_ATTR_SCREEN_WORKING_BRIGHTNESS] = 5, - [PRIVATE_ATTR_SCREEN_STANDBY_BRIGHTNESS] = 0, - [PRIVATE_ATTR_SCREEN_NIGHT_STANDBY_BRIGHTNESS] = 0, - [PRIVATE_ATTR_SCREEN_NIGHT_MODE_ENABLE] = false, - [PRIVATE_ATTR_OVERHEAT_PROTECTION_TEMPERATURE] = 3350, - [PRIVATE_ATTR_OVERHEAT_PROTECTION_ENABLE] = false, - [PRIVATE_ATTR_RADAR_ENABLE] = false -} - -local emit_simple_private_attribute -local write_temperature_control_threshold - --- Emits an event only when the active profile declares the capability. -local function emit_event_if_supported(device, capability, event) - -- This keeps sub-driver code safe if the profile is temporarily pared down. - if device:supports_capability_by_id(capability.ID) then - device:emit_event(event) - end -end - --- Emits the supported thermostat mode list expected by TP-WGZBA. -local function emit_supported_modes(device) - -- The device only exposes off, auto, and heat in the first version. - device:emit_event(ThermostatMode.supportedThermostatModes(SUPPORTED_MODES, { visibility = { displayed = false } })) -end - --- Seeds writable main-page capability defaults only when the driver has no cached state yet. -local function emit_main_page_defaults_if_needed(device) - -- Keep initial controls clickable during join-time gaps or when a vendor report is temporarily missing. - if device:get_latest_state("main", OpenWindowState.ID, "openWindowState", nil) == nil then - emit_event_if_supported(device, OpenWindowState, OpenWindowState.openWindowState(OPEN_WINDOW_STATE_CLOSED)) - end - if device:get_latest_state("main", NtcTemperature.ID, "ntcTemperature", nil) == nil then - emit_event_if_supported(device, NtcTemperature, NtcTemperature.ntcTemperature("Not Connected")) - end - if device:get_latest_state("main", TemperatureCompensation.ID, "temperatureCompensation", nil) == nil then - emit_event_if_supported(device, TemperatureCompensation, TemperatureCompensation.temperatureCompensation({ value = 0.0, unit = UNIT_CELSIUS })) - end - - for attribute_id, raw_value in pairs(INITIAL_SIMPLE_PRIVATE_ATTRIBUTE_DEFAULTS) do - local info = SIMPLE_PRIVATE_ATTRIBUTE_INFO[attribute_id] - if info ~= nil and device:get_latest_state("main", info.capability.ID, info.attribute_name, nil) == nil then - emit_simple_private_attribute(device, attribute_id, raw_value) - end - end - - -- Seed the split threshold pair with the firmware defaults until the real struct arrives. - if device:get_latest_state("main", TempCtrlThreshLow.ID, "tempCtrlThreshLow", nil) == nil then - emit_event_if_supported( - device, - TempCtrlThreshLow, - TempCtrlThreshLow.tempCtrlThreshLow({ value = PRIVATE_TEMP_CTRL_THRESH_LOW_DEFAULT, unit = UNIT_CELSIUS }) - ) - end - if device:get_latest_state("main", TempCtrlThreshHigh.ID, "tempCtrlThreshHigh", nil) == nil then - emit_event_if_supported( - device, - TempCtrlThreshHigh, - TempCtrlThreshHigh.tempCtrlThreshHigh({ value = PRIVATE_TEMP_CTRL_THRESH_HIGH_DEFAULT, unit = UNIT_CELSIUS }) - ) - end - - -- Seed both time-period pairs so the UI remains operable before the first structure report. - if device:get_latest_state("main", RadarDndStartMin.ID, "radarDndStartMin", nil) == nil then - emit_event_if_supported(device, RadarDndStartMin, RadarDndStartMin.radarDndStartMin({ value = 0, unit = UNIT_MINUTES })) - end - if device:get_latest_state("main", RadarDndEndMin.ID, "radarDndEndMin", nil) == nil then - emit_event_if_supported(device, RadarDndEndMin, RadarDndEndMin.radarDndEndMin({ value = 0, unit = UNIT_MINUTES })) - end - if device:get_latest_state("main", ScreenNightStartMin.ID, "screenNightStartMin", nil) == nil then - emit_event_if_supported(device, ScreenNightStartMin, ScreenNightStartMin.screenNightStartMin({ value = 0, unit = UNIT_MINUTES })) - end - if device:get_latest_state("main", ScreenNightEndMin.ID, "screenNightEndMin", nil) == nil then - emit_event_if_supported(device, ScreenNightEndMin, ScreenNightEndMin.screenNightEndMin({ value = 0, unit = UNIT_MINUTES })) - end - - -- Seed relay contact polarity with the firmware default bitmap until the real value is reported. - if device:get_latest_state("main", RelayOutTypeR1.ID, "relayOneOutputType", nil) == nil then - emit_event_if_supported(device, RelayOutTypeR1, RelayOutTypeR1.relayOneOutputType(RELAY_OUTPUT_TYPE_NORMALLY_OPEN)) - end - if device:get_latest_state("main", RelayOutTypeR2.ID, "relayTwoOutputType", nil) == nil then - emit_event_if_supported(device, RelayOutTypeR2, RelayOutTypeR2.relayTwoOutputType(RELAY_OUTPUT_TYPE_NORMALLY_OPEN)) - end -end - --- Reads a SONOFF private cluster attribute using the eWeLink manufacturer code. -local function read_private_attribute(device, attribute_id) - -- Manufacturer-specific reads are required for the 0xFC11 vendor cluster. - return cluster_base.read_manufacturer_specific_attribute(device, SONOFF_PRIVATE_CLUSTER, attribute_id, SONOFF_MFG_CODE) -end - --- Writes a SONOFF private cluster attribute with the provided typed payload. -local function write_private_attribute(device, attribute_id, data_type, value) - -- Preserve numeric precision by letting the Zigbee type builder own encoding. - return cluster_base.write_manufacturer_specific_attribute(device, SONOFF_PRIVATE_CLUSTER, attribute_id, SONOFF_MFG_CODE, data_type, value) -end - --- Sends a SONOFF private cluster-specific command with a raw payload body. -local function send_private_cluster_command(device, command_id, payload) - -- The private command lives on the vendor cluster rather than a standard Zigbee attribute write path. - local endpoint = device:get_endpoint(SONOFF_PRIVATE_CLUSTER) or device.fingerprinted_endpoint_id or 1 - local zcl_header = ZigbeeZcl.ZclHeader({ - cmd = data_types.ZCLCommandId(command_id) - }) - zcl_header.frame_ctrl:set_cluster_specific() - - return ZigbeeMessages.ZigbeeMessageTx({ - address_header = ZigbeeMessages.AddressHeader( - ZigbeeConstants.HUB.ADDR, - ZigbeeConstants.HUB.ENDPOINT, - device:get_short_address(), - endpoint, - ZigbeeConstants.HA_PROFILE_ID, - SONOFF_PRIVATE_CLUSTER - ), - body = ZigbeeZcl.ZclMessageBody({ - zcl_header = zcl_header, - zcl_body = generic_body.GenericBody(payload) - }) - }) -end - --- Converts a wrapped Zigbee value to its raw payload value. -local function get_attribute_value(value) - -- Attr handlers receive both raw tables and typed wrappers depending on the path. - if type(value) == "table" and value.value ~= nil then - return value.value - end - return value -end - --- Returns raw bytes from a Zigbee payload object when the private attr uses an opaque byte string. -local function get_payload_bytes(value) - -- Vendor message attributes may arrive as raw strings or nested wrappers. - if type(value) == "string" then - return value - end - if type(value) == "table" then - if type(value.value) == "string" then - return value.value - end - if type(value.value) == "table" and type(value.value.value) == "string" then - return value.value.value - end - end - return nil -end - --- Converts a SmartThings number-like value to a Lua number. -local function coerce_numeric_value(value) - -- Capability arguments may arrive as either numbers or decimal strings. - if type(value) == "number" then - return value - end - if type(value) == "string" and value ~= "" then - return tonumber(value) - end - return nil -end - --- Returns the latest numeric state for a capability, or the provided fallback. -local function get_latest_numeric_state(device, capability, attribute_name, fallback) - -- Temporary mode duration and target temperature are UI-side cached values. - local cached_value = device:get_latest_state("main", capability.ID, attribute_name, fallback) - local numeric_value = coerce_numeric_value(cached_value) - if numeric_value == nil then - return fallback - end - return numeric_value -end - --- Snaps a minute value to the device's 30-minute grid and valid range. -local function normalize_half_hour_minutes(value) - -- The device stores radar and screen periods in 30-minute steps only. - local numeric_value = coerce_numeric_value(value) - if numeric_value == nil then - return nil - end - - numeric_value = utils.clamp_value(utils.round(numeric_value), 0, PRIVATE_TIME_MAX_MINUTES) - local snapped_value = math.floor((numeric_value + (PRIVATE_TIME_STEP_MINUTES / 2)) / PRIVATE_TIME_STEP_MINUTES) * PRIVATE_TIME_STEP_MINUTES - return math.min(snapped_value, PRIVATE_TIME_MAX_MINUTES - (PRIVATE_TIME_MAX_MINUTES % PRIVATE_TIME_STEP_MINUTES)) -end - --- Encodes an unsigned 16-bit value in little-endian order. -local function encode_uint16_le(value) - -- Avoid string.pack so this runs on the stock SmartThings Lua runtime. - local normalized = math.floor(value) % 0x10000 - return string.char(normalized % 0x100, math.floor(normalized / 0x100)) -end - --- Encodes a signed 16-bit value in little-endian order. -local function encode_int16_le(value) - -- Vendor commands store temperatures as signed centi-degrees. - local normalized = math.floor(value) - if normalized < 0 then - normalized = normalized + 0x10000 - end - return encode_uint16_le(normalized) -end - --- Encodes an unsigned 32-bit value in little-endian order. -local function encode_uint32_le(value) - -- Vendor commands store duration in seconds across four bytes. - local normalized = math.floor(value) - local b1 = normalized % 0x100 - local b2 = math.floor(normalized / 0x100) % 0x100 - local b3 = math.floor(normalized / 0x10000) % 0x100 - local b4 = math.floor(normalized / 0x1000000) % 0x100 - return string.char(b1, b2, b3, b4) -end - --- Decodes an unsigned 16-bit value from a raw little-endian payload offset. -local function decode_uint16_le(raw, offset) - -- Some private structure reports arrive as raw bytes rather than decoded tables. - local low_byte, high_byte = raw:byte(offset, offset + 1) - if low_byte == nil or high_byte == nil then - return nil - end - return low_byte + (high_byte * 0x100) -end - --- Decodes a signed 16-bit value from a raw little-endian payload offset. -local function decode_int16_le(raw, offset) - -- Threshold payloads store signed centi-degree values in two-byte little-endian form. - local value = decode_uint16_le(raw, offset) - if value == nil then - return nil - end - if value >= 0x8000 then - return value - 0x10000 - end - return value -end - --- Encodes the relay-output bitmap for relay1 and relay2. -local function encode_relay_output_bitmap(relay1, relay2) - -- Bit0 is relay1 and bit1 is relay2 in the vendor bitmap. - return (relay1 % 2) + ((relay2 % 2) * 2) -end - --- Encodes the start/end minute structure used by radar DND and screen night periods. -local function encode_time_period(start_minute, end_minute) - -- Both vendor attributes use a Uint16 pair in local minutes from midnight. - return data_types.Structure({ - data_types.Uint16(start_minute), - data_types.Uint16(end_minute) - }) -end - --- Emits a boolean-style capability event using enabled/disabled states. -local function emit_enabled_disabled_event(device, info, raw_value) - -- The custom SONOFF boolean capabilities use string states instead of bare booleans. - local event_value = raw_value and CAPABILITY_STATE_ENABLED or CAPABILITY_STATE_DISABLED - emit_event_if_supported(device, info.capability, info.capability[info.attribute_name](event_value)) -end - --- Emits a scalar capability event after scaling and clamping. -local function emit_numeric_event(device, info, raw_value) - -- Convert vendor integer storage to the capability's display unit. - local event_value = raw_value - if info.scale ~= nil then - event_value = raw_value / info.scale - end - if info.unit ~= nil then - emit_event_if_supported(device, info.capability, info.capability[info.attribute_name]({ value = event_value, unit = info.unit })) - else - emit_event_if_supported(device, info.capability, info.capability[info.attribute_name](event_value)) - end -end - --- Emits an enum-style capability event from the vendor value map. -local function emit_enum_event(device, info, raw_value) - -- Ignore unknown enum values to avoid poisoning capability state. - local mapped_value = info.enum_values and info.enum_values[raw_value] or nil - if mapped_value ~= nil then - emit_event_if_supported(device, info.capability, info.capability[info.attribute_name](mapped_value)) - end -end - --- Converts a vendor attribute report into the corresponding capability event. -emit_simple_private_attribute = function(device, attribute_id, raw_value) - -- A single table keeps most private mappings declarative and easy to audit. - local info = SIMPLE_PRIVATE_ATTRIBUTE_INFO[attribute_id] - if info == nil or info.capability == nil or info.attribute_name == nil then - return - end - - if info.kind == "bool" then - emit_enabled_disabled_event(device, info, raw_value == true) - elseif info.kind == "enum" then - emit_enum_event(device, info, raw_value) - else - emit_numeric_event(device, info, raw_value) - end -end - --- Converts a capability command value into the vendor payload for a simple private attribute. -local function encode_simple_private_capability_value(info, requested_value) - -- Keep encoding rules centralized so handlers remain trivial. - if info.kind == "bool" then - if requested_value == CAPABILITY_STATE_ENABLED then - return true - end - if requested_value == CAPABILITY_STATE_DISABLED then - return false - end - return nil - end - - if info.kind == "enum" then - return info.reverse_enum_values and info.reverse_enum_values[requested_value] or nil - end - - if type(requested_value) ~= "number" then - return nil - end - - local numeric_value = requested_value - if info.min ~= nil then - numeric_value = math.max(info.min, numeric_value) - end - if info.max ~= nil then - numeric_value = math.min(info.max, numeric_value) - end - if info.scale ~= nil then - numeric_value = utils.round(numeric_value * info.scale) - else - numeric_value = utils.round(numeric_value) - end - return numeric_value -end - --- Writes a simple private capability value through the shared vendor attribute path. -local function set_simple_private_capability(driver, device, command, attribute_id) - -- Most SONOFF main-page controls map directly to one FC11 private attribute. - local info = SIMPLE_PRIVATE_ATTRIBUTE_INFO[attribute_id] - if info == nil then - return - end - - local requested_value = command.args[info.argument_name] - local encoded_value = encode_simple_private_capability_value(info, requested_value) - if encoded_value == nil then - log.warn("Invalid TP-WGZBA capability value for " .. info.argument_name) - return - end - - device:send(write_private_attribute(device, attribute_id, info.data_type, encoded_value)) - emit_simple_private_attribute(device, attribute_id, encoded_value) -end - --- Writes the vendor threshold struct after one side of the pair changes. -local function write_temperature_control_threshold_from_capability(device, low_value, high_value) - -- The device stores both hysteresis values in one structure attribute. - return write_temperature_control_threshold(device, low_value, high_value) -end - --- Writes the vendor time-period struct after one side of the pair changes. -local function write_time_period_attribute(device, attribute_id, start_minute, end_minute) - -- The device expects both start and end minutes together in one structure write. - local normalized_start = normalize_half_hour_minutes(start_minute) - local normalized_end = normalize_half_hour_minutes(end_minute) - if normalized_start == nil or normalized_end == nil then - return false - end - - device:send(write_private_attribute( - device, - attribute_id, - data_types.Structure, - encode_time_period(normalized_start, normalized_end) - )) - if attribute_id == PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_PERIOD then - emit_event_if_supported(device, RadarDndStartMin, RadarDndStartMin.radarDndStartMin({ value = normalized_start, unit = UNIT_MINUTES })) - emit_event_if_supported(device, RadarDndEndMin, RadarDndEndMin.radarDndEndMin({ value = normalized_end, unit = UNIT_MINUTES })) - elseif attribute_id == PRIVATE_ATTR_SCREEN_NIGHT_MODE_PERIOD then - emit_event_if_supported(device, ScreenNightStartMin, ScreenNightStartMin.screenNightStartMin({ value = normalized_start, unit = UNIT_MINUTES })) - emit_event_if_supported(device, ScreenNightEndMin, ScreenNightEndMin.screenNightEndMin({ value = normalized_end, unit = UNIT_MINUTES })) - end - return true -end - --- Converts relay capability values to the vendor bit representation. -local function coerce_relay_output_value(value) - -- The custom relay capabilities expose open/closed contact names. - if value == RELAY_OUTPUT_TYPE_NORMALLY_OPEN or value == 0 or value == "0" then - return 0 - end - if value == RELAY_OUTPUT_TYPE_NORMALLY_CLOSED or value == 1 or value == "1" then - return 1 - end - return nil -end - --- Writes the split relay states back into the vendor bitmap. -local function write_relay_output_type(device, relay1_value, relay2_value) - -- Both relay bits must be rewritten together because the device stores a bitmap. - local relay1 = coerce_relay_output_value(relay1_value) - local relay2 = coerce_relay_output_value(relay2_value) - if relay1 == nil or relay2 == nil then - return false - end - - device:send(write_private_attribute(device, PRIVATE_ATTR_RELAY_OUTPUT_TYPE_BITMAP, data_types.Bitmap8, encode_relay_output_bitmap(relay1, relay2))) - emit_event_if_supported( - device, - RelayOutTypeR1, - RelayOutTypeR1.relayOneOutputType(relay1 == 1 and RELAY_OUTPUT_TYPE_NORMALLY_CLOSED or RELAY_OUTPUT_TYPE_NORMALLY_OPEN) - ) - emit_event_if_supported( - device, - RelayOutTypeR2, - RelayOutTypeR2.relayTwoOutputType(relay2 == 1 and RELAY_OUTPUT_TYPE_NORMALLY_CLOSED or RELAY_OUTPUT_TYPE_NORMALLY_OPEN) - ) - return true -end - --- Decodes the vendor HVAC message payload into a simple open-window state. -local function decode_open_window_state(value) - -- The private HVAC payload is a short TLV packet where tag 0 reports the window state. - local raw = get_payload_bytes(value) - if raw == nil or #raw < 3 then - return false - end - - return raw:byte(1) == PRIVATE_HVAC_OPEN_WINDOW_TLV - and raw:byte(2) == PRIVATE_HVAC_OPEN_WINDOW_TLV_LENGTH - and raw:byte(3) ~= 0 -end - --- Decodes the vendor NTC raw temperature into a single display string. -local function decode_ntc_temperature_display(value) - -- Any invalid or fault code should collapse to one user-facing disconnected state. - local raw_value = get_attribute_value(value) - if raw_value == nil then - return "Not Connected" - end - if raw_value == PRIVATE_NTC_INVALID_CODE then - return "Not Connected" - end - if raw_value == PRIVATE_NTC_OPEN_CIRCUIT_CODE then - return "Not Connected" - end - if raw_value == PRIVATE_NTC_SHORT_CIRCUIT_CODE then - return "Not Connected" - end - if raw_value == PRIVATE_NTC_ADC_INVALID_CODE then - return "Not Connected" - end - if raw_value < PRIVATE_NTC_MIN_VALID then - return "Not Connected" - end - return string.format("%.2f C", raw_value / 100.0) -end - --- Converts the vendor temporary-mode byte to the published override status string. -local function decode_temporary_mode_state(raw_value) - -- The device reports only none, boost, and timer for this attribute. - if raw_value == TEMPORARY_MODE_ATTR_BOOST then - return TEMPORARY_MODE_STATE_BOOST - end - if raw_value == TEMPORARY_MODE_ATTR_TIMER then - return TEMPORARY_MODE_STATE_TIMER - end - if raw_value == TEMPORARY_MODE_ATTR_NONE then - return TEMPORARY_MODE_STATE_NONE - end - return nil -end - --- Seeds the override capability defaults when the platform cache is still empty. -local function emit_temporary_mode_defaults_if_needed(device) - -- These capability values are UI inputs, so they need local defaults before any write happens. - if device:get_latest_state("main", TemporaryModeStatus.ID, "temporaryTemperatureModeSettings", nil) == nil then - emit_event_if_supported(device, TemporaryModeStatus, TemporaryModeStatus.temporaryTemperatureModeSettings(TEMPORARY_MODE_STATE_NONE)) - end - if device:get_latest_state("main", TemporaryModeDuration.ID, "temporaryModeTimeSet", nil) == nil then - emit_event_if_supported(device, TemporaryModeDuration, TemporaryModeDuration.temporaryModeTimeSet({ value = 30, unit = UNIT_MINUTES })) - end - if device:get_latest_state("main", TemporaryModeTemperature.ID, "temporaryModeTempSet", nil) == nil then - emit_event_if_supported(device, TemporaryModeTemperature, TemporaryModeTemperature.temporaryModeTempSet({ value = 30.0, unit = UNIT_CELSIUS })) - end - if device:get_latest_state("main", TemporaryModeAction.ID, "temporaryModeAction", nil) == nil then - emit_event_if_supported(device, TemporaryModeAction, TemporaryModeAction.temporaryModeAction(TEMPORARY_MODE_ACTION_EXIT)) - end -end - --- Writes both temperature-control thresholds together using the vendor structure payload. -write_temperature_control_threshold = function(device, low_value, high_value) - -- Attribute 0x601F stores the low/high hysteresis pair atomically in one struct. - if type(low_value) ~= "number" or type(high_value) ~= "number" then - return false - end - - local clamped_low = utils.clamp_value(low_value, PRIVATE_TEMP_CTRL_THRESH_LOW_MIN, PRIVATE_TEMP_CTRL_THRESH_LOW_MAX) - local clamped_high = utils.clamp_value(high_value, PRIVATE_TEMP_CTRL_THRESH_HIGH_MIN, PRIVATE_TEMP_CTRL_THRESH_HIGH_MAX) - local threshold_value = data_types.Structure({ - data_types.Int16(utils.round(clamped_low * 100)), - data_types.Int16(utils.round(clamped_high * 100)) - }) - - device:send(write_private_attribute( - device, - PRIVATE_ATTR_TEMPERATURE_CONTROL_THRESHOLD, - data_types.Structure, - threshold_value - )) - emit_event_if_supported(device, TempCtrlThreshLow, TempCtrlThreshLow.tempCtrlThreshLow({ value = clamped_low, unit = UNIT_CELSIUS })) - emit_event_if_supported(device, TempCtrlThreshHigh, TempCtrlThreshHigh.tempCtrlThreshHigh({ value = clamped_high, unit = UNIT_CELSIUS })) - return true -end - --- Emits the override status reported by the vendor temporary-mode attribute. -local function private_temporary_mode_settings_handler(driver, device, value, zb_rx) - -- Ignore unknown states so the app never shows a misleading override mode. - local event_value = decode_temporary_mode_state(get_attribute_value(value)) - if event_value ~= nil then - emit_event_if_supported(device, TemporaryModeStatus, TemporaryModeStatus.temporaryTemperatureModeSettings(event_value)) - end -end - --- Emits the read-only open-window state extracted from the private HVAC message payload. -local function private_hvac_message_handler(driver, device, value, zb_rx) - -- Keep the detection state separate from the writable open-window feature toggle. - local is_open = decode_open_window_state(value) - local event_value = is_open and OPEN_WINDOW_STATE_OPEN or OPEN_WINDOW_STATE_CLOSED - emit_event_if_supported(device, OpenWindowState, OpenWindowState.openWindowState(event_value)) -end - --- Emits the read-only NTC display string derived from the private raw temperature attribute. -local function private_ntc_temperature_handler(driver, device, value, zb_rx) - -- Keep the UI on one field so App layout stays compact and predictable. - emit_event_if_supported(device, NtcTemperature, NtcTemperature.ntcTemperature(decode_ntc_temperature_display(value))) -end - --- Extracts a Lua number from one member of a Zigbee structure payload. -local function get_structure_member_number(member) - -- Attribute reports may wrap each structure element in nested typed tables. - if type(member) == "number" then - return member - end - if type(member) == "table" and member.value ~= nil then - return get_structure_member_number(member.value) - end - if type(member) == "string" then - local hex_value = member:match("0x([%x]+)") - if hex_value ~= nil then - return tonumber(hex_value, 16) - end - local decimal_value = member:match("(-?%d+)") - if decimal_value ~= nil then - return tonumber(decimal_value) - end - end - - local text = tostring(member) - if type(text) == "string" then - local hex_value = text:match("0x([%x]+)") - if hex_value ~= nil then - return tonumber(hex_value, 16) - end - local decimal_value = text:match("(-?%d+)") - if decimal_value ~= nil then - return tonumber(decimal_value) - end - end - return nil -end - --- Extracts the first two numeric members from a Zigbee structure payload. -local function get_structure_pair_numbers(value) - -- SmartThings may surface structure members as indexed fields, nested values, or stringified typed values. - local candidates = { - value, - get_attribute_value(value) - } - - for _, candidate in ipairs(candidates) do - if type(candidate) == "table" then - local first = get_structure_member_number(candidate[1]) - local second = get_structure_member_number(candidate[2]) - if first ~= nil and second ~= nil then - return first, second - end - - if type(candidate.value) == "table" then - first = get_structure_member_number(candidate.value[1]) - second = get_structure_member_number(candidate.value[2]) - if first ~= nil and second ~= nil then - return first, second - end - end - end - end - - local text = tostring(value) - if type(text) == "string" then - local numbers = {} - for hex_value in text:gmatch(":%s*0x([%x]+)") do - table.insert(numbers, tonumber(hex_value, 16)) - end - if #numbers >= 2 then - return numbers[1], numbers[2] - end - - numbers = {} - for decimal_value in text:gmatch(":%s*(-?%d+)") do - table.insert(numbers, tonumber(decimal_value)) - end - if #numbers >= 2 then - return numbers[1], numbers[2] - end - end - - return nil, nil -end - --- Decodes a SONOFF start/end minute structure into two Lua numbers. -local function decode_time_period(value) - -- Both vendor time-period attributes are reported as a two-member structure. - return get_structure_pair_numbers(value) -end - --- Builds a generic attr handler for simple private attributes. -local function simple_private_attribute_handler(attribute_id) - -- Most TP-WGZBA vendor attributes are simple bool/int/enum values. - return function(driver, device, value, zb_rx) - emit_simple_private_attribute(device, attribute_id, get_attribute_value(value)) - end -end - --- Emits the split low/high threshold capability values from the vendor structure payload. -local function private_temperature_control_threshold_handler(driver, device, value, zb_rx) - -- The threshold attribute reports both hysteresis values atomically. - local low_threshold, high_threshold = get_structure_pair_numbers(value) - if low_threshold ~= nil and high_threshold ~= nil then - emit_event_if_supported(device, TempCtrlThreshLow, TempCtrlThreshLow.tempCtrlThreshLow({ value = low_threshold / 100.0, unit = UNIT_CELSIUS })) - emit_event_if_supported(device, TempCtrlThreshHigh, TempCtrlThreshHigh.tempCtrlThreshHigh({ value = high_threshold / 100.0, unit = UNIT_CELSIUS })) - return - end - - local raw = get_payload_bytes(value) - if raw == nil or #raw < 8 then - log.warn("Invalid TP-WGZBA temperature control threshold payload") - return - end - - local low_threshold = decode_int16_le(raw, 4) - local high_threshold = decode_int16_le(raw, 7) - if low_threshold == nil or high_threshold == nil then - log.warn("Incomplete TP-WGZBA temperature control threshold payload") - return - end - - emit_event_if_supported(device, TempCtrlThreshLow, TempCtrlThreshLow.tempCtrlThreshLow({ value = low_threshold / 100.0, unit = UNIT_CELSIUS })) - emit_event_if_supported(device, TempCtrlThreshHigh, TempCtrlThreshHigh.tempCtrlThreshHigh({ value = high_threshold / 100.0, unit = UNIT_CELSIUS })) -end - --- Emits the split relay output type capability values from the vendor bitmap payload. -local function private_relay_output_type_handler(driver, device, value, zb_rx) - -- Relay bits 0 and 1 encode heating and boiler contact polarity. - local raw_value = get_attribute_value(value) - if type(raw_value) ~= "number" then - return - end - - local relay_one = (raw_value % 2) == 1 and RELAY_OUTPUT_TYPE_NORMALLY_CLOSED or RELAY_OUTPUT_TYPE_NORMALLY_OPEN - local relay_two = (math.floor(raw_value / 2) % 2) == 1 and RELAY_OUTPUT_TYPE_NORMALLY_CLOSED or RELAY_OUTPUT_TYPE_NORMALLY_OPEN - emit_event_if_supported(device, RelayOutTypeR1, RelayOutTypeR1.relayOneOutputType(relay_one)) - emit_event_if_supported(device, RelayOutTypeR2, RelayOutTypeR2.relayTwoOutputType(relay_two)) -end - --- Emits the start/end minute capability values from the vendor time period structure. -local function emit_time_period_events(device, value, start_capability, end_capability, start_attribute_name, end_attribute_name) - -- Radar DND and screen night mode share the same minute-pair encoding. - local start_minute, end_minute = decode_time_period(value) - if start_minute ~= nil and end_minute ~= nil then - emit_event_if_supported(device, start_capability, start_capability[start_attribute_name]({ value = start_minute, unit = UNIT_MINUTES })) - emit_event_if_supported(device, end_capability, end_capability[end_attribute_name]({ value = end_minute, unit = UNIT_MINUTES })) - return - end - - local raw = get_payload_bytes(value) - if raw == nil or #raw < 8 then - log.warn("Invalid TP-WGZBA time period payload") - return - end - - start_minute = decode_uint16_le(raw, 4) - end_minute = decode_uint16_le(raw, 7) - if start_minute == nil or end_minute == nil then - log.warn("Incomplete TP-WGZBA time period payload") - return - end - - emit_event_if_supported(device, start_capability, start_capability[start_attribute_name]({ value = start_minute, unit = UNIT_MINUTES })) - emit_event_if_supported(device, end_capability, end_capability[end_attribute_name]({ value = end_minute, unit = UNIT_MINUTES })) -end - --- Stores the decoded radar DND start/end minutes from the vendor structure report. -local function private_radar_do_not_disturb_period_handler(driver, device, value, zb_rx) - -- Keep the reported minute pair visible directly on the main page capability. - emit_time_period_events(device, value, RadarDndStartMin, RadarDndEndMin, "radarDndStartMin", "radarDndEndMin") -end - --- Stores the decoded screen night start/end minutes from the vendor structure report. -local function private_screen_night_mode_period_handler(driver, device, value, zb_rx) - -- Keep the reported minute pair visible directly on the main page capability. - emit_time_period_events(device, value, ScreenNightStartMin, ScreenNightEndMin, "screenNightStartMin", "screenNightEndMin") -end - --- Refreshes all standard and selected private attributes used by the first version. -local function do_refresh(self, device) - -- Keep refresh bounded to validated attributes so troubleshooting stays manageable. - device:send(Thermostat.attributes.LocalTemperature:read(device)) - device:send(Thermostat.attributes.OccupiedHeatingSetpoint:read(device)) - device:send(Thermostat.attributes.SystemMode:read(device)) - device:send(Thermostat.attributes.ThermostatRunningState:read(device)) - device:send(Thermostat.attributes.LocalTemperatureCalibration:read(device)) - device:send(Basic.attributes.SWBuildID:read(device)) - for _, attribute_id in ipairs(PRIVATE_REFRESH_ATTRIBUTES) do - device:send(read_private_attribute(device, attribute_id)) - end -end - --- Publishes the device-provided Basic Cluster software build identifier as its firmware version. -local function software_build_id_handler(driver, device, value, zb_rx) - -- Ignore malformed reports because the capability requires a non-empty string. - if type(value.value) ~= "string" or value.value == "" then - log.warn("Invalid TP-WGZBA Basic SWBuildID payload") - return - end - - emit_event_if_supported(device, FirmwareUpdate, FirmwareUpdate.currentVersion({ value = value.value })) -end - --- Configures standard thermostat reporting only. -local function do_configure(self, device) - -- Keep the standard bind but do not configure automatic reporting. - device:send(device_management.build_bind_request(device, Thermostat.ID, self.environment_info.hub_zigbee_eui)) -end - --- Handles initial device join state. -local function device_added(self, device) - -- Seed capability defaults before the first vendor refresh arrives. - emit_supported_modes(device) - emit_main_page_defaults_if_needed(device) - emit_temporary_mode_defaults_if_needed(device) - do_refresh(self, device) -end - --- Handles driver switch the same way as first add. -local function device_driver_switched(self, device) - -- Driver replacement should refresh state from the device again. - emit_supported_modes(device) - emit_main_page_defaults_if_needed(device) - emit_temporary_mode_defaults_if_needed(device) - do_refresh(self, device) -end - --- Restores volatile UI state on driver init. -local function device_init(self, device) - -- Override inputs are UI-cached values and still need local defaults before the first interaction. - emit_main_page_defaults_if_needed(device) - emit_temporary_mode_defaults_if_needed(device) -end - --- Writes the standard thermostat system mode and schedules a readback. -local function set_thermostat_mode(driver, device, command) - -- Write the Zigbee mode directly instead of remapping app semantics. - local mode = THERMOSTAT_MODE_MAP[command.args.mode] - if mode == nil then - return - end - - device:send_to_component(command.component, Thermostat.attributes.SystemMode:write(device, mode)) -end - --- Creates dedicated thermostat-mode command handlers. -local function thermostat_mode_setter(mode_name) - -- Reuse the common setter so all mode commands share one write path. - return function(driver, device, command) - set_thermostat_mode(driver, device, { component = command.component, args = { mode = mode_name } }) - end -end - --- Writes the standard heating setpoint and schedules a readback. -local function set_heating_setpoint(driver, device, command) - -- Keep the first version on the standard setpoint attribute only. - local value = command.args.setpoint - if value >= 40 then - value = utils.f_to_c(value) - end - - value = utils.clamp_value(value, 5.0, 30.0) - device:send_to_component(command.component, Thermostat.attributes.OccupiedHeatingSetpoint:write(device, utils.round(value * 100))) -end - --- Sends the vendor temporary mode command built from the current UI-side override values. -local function send_temporary_mode_command(device, action_value) - -- Exit carries only the action byte, while boost and timer include duration and target temperature. - local duration_minutes = utils.clamp_value( - utils.round(get_latest_numeric_state(device, TemporaryModeDuration, "temporaryModeTimeSet", 30)), - 1, - 1439 - ) - - if action_value == TEMPORARY_MODE_EXIT then - device:send(send_private_cluster_command(device, TEMPORARY_MODE_COMMAND_ID, string.char(action_value))) - return - end - - local target_temperature = 30.0 - if action_value == TEMPORARY_MODE_TIMER then - target_temperature = utils.clamp_value( - get_latest_numeric_state(device, TemporaryModeTemperature, "temporaryModeTempSet", 30.0), - 5.0, - 30.0 - ) - elseif action_value ~= TEMPORARY_MODE_BOOST then - log.warn("Invalid TP-WGZBA temporary mode action value") - return - end - - local payload = string.char(action_value) - .. encode_uint32_le(duration_minutes * 60) - .. encode_int16_le(utils.round(target_temperature * 100)) - device:send(send_private_cluster_command(device, TEMPORARY_MODE_COMMAND_ID, payload)) -end - --- Updates the locally cached override duration without writing a vendor attribute. -local function set_temporary_mode_duration(driver, device, command) - -- The duration is only consumed when the user later sends boost or timer. - local requested_value = coerce_numeric_value(command.args.temporaryModeTimeSet) - if requested_value == nil then - log.warn("Invalid TP-WGZBA temporary mode duration value") - return - end - - requested_value = utils.clamp_value(utils.round(requested_value), 1, 1439) - emit_event_if_supported(device, TemporaryModeDuration, TemporaryModeDuration.temporaryModeTimeSet({ value = requested_value, unit = UNIT_MINUTES })) -end - --- Updates the locally cached override target temperature without writing a vendor attribute. -local function set_temporary_mode_temperature(driver, device, command) - -- Timer mode uses this cached value, while boost always forces 30C on-device. - local requested_value = coerce_numeric_value(command.args.temporaryModeTempSet) - if requested_value == nil then - log.warn("Invalid TP-WGZBA temporary mode temperature value") - return - end - - requested_value = utils.clamp_value(requested_value, 5.0, 30.0) - emit_event_if_supported(device, TemporaryModeTemperature, TemporaryModeTemperature.temporaryModeTempSet({ value = requested_value, unit = UNIT_CELSIUS })) -end - --- Sends the selected override action and then refreshes the reported vendor status. -local function set_temporary_mode_action(driver, device, command) - -- Only exit, boost, and timer are supported by the published override action capability. - local requested_value = command.args.temporaryModeAction - if requested_value == TEMPORARY_MODE_ACTION_EXIT then - send_temporary_mode_command(device, TEMPORARY_MODE_EXIT) - elseif requested_value == TEMPORARY_MODE_ACTION_BOOST then - send_temporary_mode_command(device, TEMPORARY_MODE_BOOST) - emit_event_if_supported(device, TemporaryModeTemperature, TemporaryModeTemperature.temporaryModeTempSet({ value = 30.0, unit = UNIT_CELSIUS })) - elseif requested_value == TEMPORARY_MODE_ACTION_TIMER then - send_temporary_mode_command(device, TEMPORARY_MODE_TIMER) - else - log.warn("Invalid TP-WGZBA temporary mode action capability value") - return - end - - emit_event_if_supported(device, TemporaryModeAction, TemporaryModeAction.temporaryModeAction(requested_value)) -end - --- Writes the vendor threshold struct after changing the low capability value. -local function set_temp_ctrl_thresh_low(driver, device, command) - -- The current high threshold must be preserved because the vendor attribute is atomic. - local low_value = coerce_numeric_value(command.args.tempCtrlThreshLow) - local high_value = get_latest_numeric_state(device, TempCtrlThreshHigh, "tempCtrlThreshHigh", PRIVATE_TEMP_CTRL_THRESH_HIGH_DEFAULT) - if low_value == nil or not write_temperature_control_threshold_from_capability(device, low_value, high_value) then - log.warn("Invalid TP-WGZBA low threshold capability value") - end -end - --- Writes the vendor threshold struct after changing the high capability value. -local function set_temp_ctrl_thresh_high(driver, device, command) - -- The current low threshold must be preserved because the vendor attribute is atomic. - local low_value = get_latest_numeric_state(device, TempCtrlThreshLow, "tempCtrlThreshLow", PRIVATE_TEMP_CTRL_THRESH_LOW_DEFAULT) - local high_value = coerce_numeric_value(command.args.tempCtrlThreshHigh) - if high_value == nil or not write_temperature_control_threshold_from_capability(device, low_value, high_value) then - log.warn("Invalid TP-WGZBA high threshold capability value") - end -end - --- Writes the radar DND struct after changing the start minute capability. -local function set_radar_dnd_start_min(driver, device, command) - -- The end minute must be preserved because both values share one structure write. - local start_minute = coerce_numeric_value(command.args.radarDndStartMin) - local end_minute = get_latest_numeric_state(device, RadarDndEndMin, "radarDndEndMin", 0) - if start_minute == nil or not write_time_period_attribute(device, PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_PERIOD, start_minute, end_minute) then - log.warn("Invalid TP-WGZBA radar DND start minute capability value") - end -end - --- Writes the radar DND struct after changing the end minute capability. -local function set_radar_dnd_end_min(driver, device, command) - -- The start minute must be preserved because both values share one structure write. - local start_minute = get_latest_numeric_state(device, RadarDndStartMin, "radarDndStartMin", 0) - local end_minute = coerce_numeric_value(command.args.radarDndEndMin) - if end_minute == nil or not write_time_period_attribute(device, PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_PERIOD, start_minute, end_minute) then - log.warn("Invalid TP-WGZBA radar DND end minute capability value") - end -end - --- Writes the screen night struct after changing the start minute capability. -local function set_screen_night_start_min(driver, device, command) - -- The end minute must be preserved because both values share one structure write. - local start_minute = coerce_numeric_value(command.args.screenNightStartMin) - local end_minute = get_latest_numeric_state(device, ScreenNightEndMin, "screenNightEndMin", 0) - if start_minute == nil or not write_time_period_attribute(device, PRIVATE_ATTR_SCREEN_NIGHT_MODE_PERIOD, start_minute, end_minute) then - log.warn("Invalid TP-WGZBA screen night start minute capability value") - end -end - --- Writes the screen night struct after changing the end minute capability. -local function set_screen_night_end_min(driver, device, command) - -- The start minute must be preserved because both values share one structure write. - local start_minute = get_latest_numeric_state(device, ScreenNightStartMin, "screenNightStartMin", 0) - local end_minute = coerce_numeric_value(command.args.screenNightEndMin) - if end_minute == nil or not write_time_period_attribute(device, PRIVATE_ATTR_SCREEN_NIGHT_MODE_PERIOD, start_minute, end_minute) then - log.warn("Invalid TP-WGZBA screen night end minute capability value") - end -end - --- Converts thermostat running-state bitmaps into the standard operating-state capability. -local function thermostat_running_state_handler(driver, device, value, zb_rx) - -- Use the device-reported state directly instead of inferring from temperatures. - if value:is_heat_second_stage_on_set() or value:is_heat_on_set() then - device:emit_event(ThermostatOperatingState.thermostatOperatingState.heating()) - else - device:emit_event(ThermostatOperatingState.thermostatOperatingState.idle()) - end -end - --- Emits the standard calibration report through the main-page temperature compensation capability. -local function local_temperature_calibration_handler(driver, device, value, zb_rx) - -- LocalTemperatureCalibration is stored in 0.1 C units by the thermostat cluster. - local raw_value = get_attribute_value(value) - local calibration = coerce_numeric_value(raw_value) - if calibration == nil then - log.warn("Invalid TP-WGZBA local temperature calibration attribute value") - return - end - - calibration = utils.clamp_value(calibration / 10.0, -10.0, 10.0) - emit_event_if_supported(device, TemperatureCompensation, TemperatureCompensation.temperatureCompensation({ value = calibration, unit = UNIT_CELSIUS })) -end - --- Writes main-page temperature compensation to standard local-temperature calibration. -local function set_temperature_compensation(driver, device, command) - -- The app sends Celsius, while Zigbee LocalTemperatureCalibration uses 0.1 C units. - local calibration = coerce_numeric_value(command.args.temperatureCompensation) - if calibration == nil then - log.warn("Invalid TP-WGZBA temperature compensation capability value") - return - end - - calibration = utils.clamp_value(calibration, -10.0, 10.0) - device:send_to_component(command.component, Thermostat.attributes.LocalTemperatureCalibration:write(device, utils.round(calibration * 10))) - emit_event_if_supported(device, TemperatureCompensation, TemperatureCompensation.temperatureCompensation({ value = calibration, unit = UNIT_CELSIUS })) -end - -local sonoff_thermostat = { - NAME = "SONOFF TP-WGZBA Handler", - zigbee_handlers = { - attr = { - [Basic.ID] = { - [Basic.attributes.SWBuildID.ID] = software_build_id_handler - }, - [Thermostat.ID] = { - [Thermostat.attributes.ThermostatRunningState.ID] = thermostat_running_state_handler, - [Thermostat.attributes.LocalTemperatureCalibration.ID] = local_temperature_calibration_handler - }, - [SONOFF_PRIVATE_CLUSTER] = { - [PRIVATE_ATTR_CHILD_LOCK] = simple_private_attribute_handler(PRIVATE_ATTR_CHILD_LOCK), - [PRIVATE_ATTR_BT_PAIRING_BROADCAST_REQ] = simple_private_attribute_handler(PRIVATE_ATTR_BT_PAIRING_BROADCAST_REQ), - [PRIVATE_ATTR_OPEN_WINDOW_DETECTION] = simple_private_attribute_handler(PRIVATE_ATTR_OPEN_WINDOW_DETECTION), - [PRIVATE_ATTR_FROST_PROOF_TEMPERATURE] = simple_private_attribute_handler(PRIVATE_ATTR_FROST_PROOF_TEMPERATURE), - [PRIVATE_ATTR_TEMPORARY_MODE_SETTINGS] = private_temporary_mode_settings_handler, - [PRIVATE_ATTR_TEMPERATURE_CONTROL_THRESHOLD] = private_temperature_control_threshold_handler, - [PRIVATE_ATTR_RADAR_SENSITIVITY_LEVEL] = simple_private_attribute_handler(PRIVATE_ATTR_RADAR_SENSITIVITY_LEVEL), - [PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_ENABLE] = simple_private_attribute_handler(PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_ENABLE), - [PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_PERIOD] = private_radar_do_not_disturb_period_handler, - [PRIVATE_ATTR_SCREEN_WORKING_BRIGHTNESS] = simple_private_attribute_handler(PRIVATE_ATTR_SCREEN_WORKING_BRIGHTNESS), - [PRIVATE_ATTR_SCREEN_STANDBY_BRIGHTNESS] = simple_private_attribute_handler(PRIVATE_ATTR_SCREEN_STANDBY_BRIGHTNESS), - [PRIVATE_ATTR_SCREEN_NIGHT_STANDBY_BRIGHTNESS] = simple_private_attribute_handler(PRIVATE_ATTR_SCREEN_NIGHT_STANDBY_BRIGHTNESS), - [PRIVATE_ATTR_SCREEN_NIGHT_MODE_ENABLE] = simple_private_attribute_handler(PRIVATE_ATTR_SCREEN_NIGHT_MODE_ENABLE), - [PRIVATE_ATTR_SCREEN_NIGHT_MODE_PERIOD] = private_screen_night_mode_period_handler, - [PRIVATE_ATTR_RELAY_OUTPUT_TYPE_BITMAP] = private_relay_output_type_handler, - [PRIVATE_ATTR_HVAC_MESSAGE_NOTIFICATION] = private_hvac_message_handler, - [PRIVATE_ATTR_CURRENT_NTC_TEMPERATURE_RAW] = private_ntc_temperature_handler, - [PRIVATE_ATTR_OVERHEAT_PROTECTION_TEMPERATURE] = simple_private_attribute_handler(PRIVATE_ATTR_OVERHEAT_PROTECTION_TEMPERATURE), - [PRIVATE_ATTR_OVERHEAT_PROTECTION_ENABLE] = simple_private_attribute_handler(PRIVATE_ATTR_OVERHEAT_PROTECTION_ENABLE), - [PRIVATE_ATTR_RADAR_ENABLE] = simple_private_attribute_handler(PRIVATE_ATTR_RADAR_ENABLE) - } - } - }, - capability_handlers = { - [capabilities.refresh.ID] = { - [capabilities.refresh.commands.refresh.NAME] = do_refresh - }, - [ThermostatMode.ID] = { - [ThermostatMode.commands.setThermostatMode.NAME] = set_thermostat_mode, - [ThermostatMode.commands.auto.NAME] = thermostat_mode_setter(ThermostatMode.thermostatMode.auto.NAME), - [ThermostatMode.commands.off.NAME] = thermostat_mode_setter(ThermostatMode.thermostatMode.off.NAME), - [ThermostatMode.commands.heat.NAME] = thermostat_mode_setter(ThermostatMode.thermostatMode.heat.NAME) - }, - [ThermostatHeatingSetpoint.ID] = { - [ThermostatHeatingSetpoint.commands.setHeatingSetpoint.NAME] = set_heating_setpoint - }, - [TemperatureCompensation.ID] = { - ["setTemperatureCompensation"] = set_temperature_compensation - }, - [ChildLock.ID] = { - [ChildLock.commands.setChildLockState.NAME] = function(driver, device, command) - set_simple_private_capability(driver, device, command, PRIVATE_ATTR_CHILD_LOCK) - end - }, - [OpenWindowDetection.ID] = { - [OpenWindowDetection.commands.setOpenWindowDetection.NAME] = function(driver, device, command) - set_simple_private_capability(driver, device, command, PRIVATE_ATTR_OPEN_WINDOW_DETECTION) - end - }, - [BtPairingBroadcastReq.ID] = { - [BtPairingBroadcastReq.commands.setBtPairingBroadcastReq.NAME] = function(driver, device, command) - set_simple_private_capability(driver, device, command, PRIVATE_ATTR_BT_PAIRING_BROADCAST_REQ) - end - }, - [FrostProofTemperature.ID] = { - [FrostProofTemperature.commands.setFrostProofTemperature.NAME] = function(driver, device, command) - set_simple_private_capability(driver, device, command, PRIVATE_ATTR_FROST_PROOF_TEMPERATURE) - end - }, - [TemporaryModeDuration.ID] = { - [TemporaryModeDuration.commands.setTemporaryModeTimeSet.NAME] = set_temporary_mode_duration - }, - [TemporaryModeTemperature.ID] = { - [TemporaryModeTemperature.commands.setTemporaryModeTempSet.NAME] = set_temporary_mode_temperature - }, - [TemporaryModeAction.ID] = { - [TemporaryModeAction.commands.setTemporaryModeAction.NAME] = set_temporary_mode_action - }, - [RadarSensitivityLevel.ID] = { - [RadarSensitivityLevel.commands.setRadarSensitivityLevel.NAME] = function(driver, device, command) - set_simple_private_capability(driver, device, command, PRIVATE_ATTR_RADAR_SENSITIVITY_LEVEL) - end - }, - [RadarDoNotDisturbEnable.ID] = { - [RadarDoNotDisturbEnable.commands.setRadarDoNotDisturbEnable.NAME] = function(driver, device, command) - set_simple_private_capability(driver, device, command, PRIVATE_ATTR_RADAR_DO_NOT_DISTURB_ENABLE) - end - }, - [RadarDndStartMin.ID] = { - [RadarDndStartMin.commands.setRadarDndStartMin.NAME] = set_radar_dnd_start_min - }, - [RadarDndEndMin.ID] = { - [RadarDndEndMin.commands.setRadarDndEndMin.NAME] = set_radar_dnd_end_min - }, - [ScreenWorkingBrightness.ID] = { - [ScreenWorkingBrightness.commands.setScreenWorkingBrightness.NAME] = function(driver, device, command) - set_simple_private_capability(driver, device, command, PRIVATE_ATTR_SCREEN_WORKING_BRIGHTNESS) - end - }, - [ScreenStandbyBrightness.ID] = { - [ScreenStandbyBrightness.commands.setScreenStandbyBrightness.NAME] = function(driver, device, command) - set_simple_private_capability(driver, device, command, PRIVATE_ATTR_SCREEN_STANDBY_BRIGHTNESS) - end - }, - [ScreenNightStandbyBrightness.ID] = { - [ScreenNightStandbyBrightness.commands.setScreenNightStandbyBrightness.NAME] = function(driver, device, command) - set_simple_private_capability(driver, device, command, PRIVATE_ATTR_SCREEN_NIGHT_STANDBY_BRIGHTNESS) - end - }, - [ScreenNightModeEnable.ID] = { - [ScreenNightModeEnable.commands.setScreenNightModeEnable.NAME] = function(driver, device, command) - set_simple_private_capability(driver, device, command, PRIVATE_ATTR_SCREEN_NIGHT_MODE_ENABLE) - end - }, - [ScreenNightStartMin.ID] = { - [ScreenNightStartMin.commands.setScreenNightStartMin.NAME] = set_screen_night_start_min - }, - [ScreenNightEndMin.ID] = { - [ScreenNightEndMin.commands.setScreenNightEndMin.NAME] = set_screen_night_end_min - }, - [RelayOutTypeR1.ID] = { - [RelayOutTypeR1.commands.setRelayOneOutputType.NAME] = function(driver, device, command) - local relay1_value = command.args.relayOneOutputType - local relay2_value = device:get_latest_state("main", RelayOutTypeR2.ID, "relayTwoOutputType", RELAY_OUTPUT_TYPE_NORMALLY_OPEN) - if not write_relay_output_type(device, relay1_value, relay2_value) then - log.warn("Invalid TP-WGZBA relay 1 output type capability value") - end - end - }, - [RelayOutTypeR2.ID] = { - [RelayOutTypeR2.commands.setRelayTwoOutputType.NAME] = function(driver, device, command) - local relay1_value = device:get_latest_state("main", RelayOutTypeR1.ID, "relayOneOutputType", RELAY_OUTPUT_TYPE_NORMALLY_OPEN) - local relay2_value = command.args.relayTwoOutputType - if not write_relay_output_type(device, relay1_value, relay2_value) then - log.warn("Invalid TP-WGZBA relay 2 output type capability value") - end - end - }, - [TempCtrlThreshLow.ID] = { - [TempCtrlThreshLow.commands.setTempCtrlThreshLow.NAME] = set_temp_ctrl_thresh_low - }, - [TempCtrlThreshHigh.ID] = { - [TempCtrlThreshHigh.commands.setTempCtrlThreshHigh.NAME] = set_temp_ctrl_thresh_high - }, - [OverheatProtectionTemperature.ID] = { - [OverheatProtectionTemperature.commands.setOverheatProtectionTemperature.NAME] = function(driver, device, command) - set_simple_private_capability(driver, device, command, PRIVATE_ATTR_OVERHEAT_PROTECTION_TEMPERATURE) - end - }, - [OverheatProtectionEnable.ID] = { - [OverheatProtectionEnable.commands.setOverheatProtectionEnable.NAME] = function(driver, device, command) - set_simple_private_capability(driver, device, command, PRIVATE_ATTR_OVERHEAT_PROTECTION_ENABLE) - end - }, - [RadarEnable.ID] = { - [RadarEnable.commands.setRadarEnable.NAME] = function(driver, device, command) - set_simple_private_capability(driver, device, command, PRIVATE_ATTR_RADAR_ENABLE) - end - } - }, - lifecycle_handlers = { - added = device_added, - init = device_init, - driverSwitched = device_driver_switched, - doConfigure = do_configure - }, - can_handle = require("sonoff.tp_wgzba.can_handle") -} - -return sonoff_thermostat 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 index 43478da03b..b3392d8a60 100644 --- 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 @@ -1,16 +1,5 @@ --- Copyright 2026 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 local test = require "integration_test" local capabilities = require "st.capabilities" @@ -20,970 +9,168 @@ 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 zcl_messages = require "st.zigbee.zcl" 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 ThermostatSystemMode = Thermostat.attributes.SystemMode - -local TemperatureCompensation = capabilities["samplereturn62595.temperatureCompensation"] -local ChildLock = capabilities["drumborder00833.childLockState"] -local OpenWindowDetection = capabilities["drumborder00833.openWindowDetection"] -local BtPairingBroadcastReq = capabilities["drumborder00833.bluetoothPairingBroadcastRequest"] -local FrostProofTemperature = capabilities["drumborder00833.frostProofTemperature"] -local OpenWindowState = capabilities["drumborder00833.openWindowState"] -local NtcTemperature = capabilities["drumborder00833.ntcTemperature"] -local TemporaryModeStatus = capabilities["drumborder00833.overrideStatus"] -local TemporaryModeDuration = capabilities["drumborder00833.overrideModeDuration"] -local TemporaryModeTemperature = capabilities["drumborder00833.overrideModeTemperature"] -local TemporaryModeAction = capabilities["drumborder00833.overrideAction"] -local TempCtrlThreshLow = capabilities["drumborder00833.temperatureControlThresholdLow"] -local TempCtrlThreshHigh = capabilities["drumborder00833.temperatureControlThresholdHigh"] -local RadarSensitivityLevel = capabilities["drumborder00833.radarSensitivityLevel"] -local RadarDoNotDisturbEnable = capabilities["drumborder00833.radarDoNotDisturbEnable"] -local RadarDndStartMin = capabilities["drumborder00833.radarDoNotDisturbStartMinute"] -local RadarDndEndMin = capabilities["drumborder00833.radarDoNotDisturbEndMinute"] -local ScreenWorkingBrightness = capabilities["drumborder00833.screenWorkingBrightness"] -local ScreenStandbyBrightness = capabilities["drumborder00833.screenStandbyBrightness"] -local ScreenNightStandbyBrightness = capabilities["drumborder00833.screenNightStandbyBrightness"] -local ScreenNightModeEnable = capabilities["drumborder00833.screenNightModeEnable"] -local ScreenNightStartMin = capabilities["drumborder00833.screenNightStartMinute"] -local ScreenNightEndMin = capabilities["drumborder00833.screenNightEndMinute"] -local RelayOutTypeR1 = capabilities["drumborder00833.relayHeatingContactType"] -local RelayOutTypeR2 = capabilities["drumborder00833.relayBoilerContactType"] -local OverheatProtectionTemperature = capabilities["drumborder00833.overheatProtectionTemperature"] -local OverheatProtectionEnable = capabilities["drumborder00833.overheatProtectionEnable"] -local RadarEnable = capabilities["drumborder00833.radarEnable"] - -local custom_capabilities_available = pcall(function() - OpenWindowState.openWindowState("closed") -end) - local MFG_CODE = 0x1286 -local SONOFF_PRIVATE_CLUSTER = 0xFC11 -local TEMPORARY_MODE_COMMAND_ID = 0x11 - -local CHILD_LOCK_ATTR = 0x0000 -local BT_PAIRING_BROADCAST_REQ_ATTR = 0x0029 -local OPEN_WINDOW_DETECTION_ATTR = 0x6000 -local FROST_PROOF_TEMPERATURE_ATTR = 0x6002 -local TEMPORARY_MODE_SETTINGS_ATTR = 0x6014 -local TEMPERATURE_CONTROL_THRESHOLD_ATTR = 0x601F -local RADAR_SENSITIVITY_LEVEL_ATTR = 0x6020 -local RADAR_DO_NOT_DISTURB_ENABLE_ATTR = 0x6021 -local RADAR_DO_NOT_DISTURB_PERIOD_ATTR = 0x6022 -local SCREEN_WORKING_BRIGHTNESS_ATTR = 0x6023 -local SCREEN_STANDBY_BRIGHTNESS_ATTR = 0x6024 -local SCREEN_NIGHT_STANDBY_BRIGHTNESS_ATTR = 0x6025 -local SCREEN_NIGHT_MODE_ENABLE_ATTR = 0x6026 -local SCREEN_NIGHT_MODE_PERIOD_ATTR = 0x6027 -local RELAY_OUTPUT_TYPE_BITMAP_ATTR = 0x6028 -local HVAC_MESSAGE_NOTIFICATION_ATTR = 0x6030 -local CURRENT_NTC_TEMPERATURE_RAW_ATTR = 0x6031 -local OVERHEAT_PROTECTION_TEMPERATURE_ATTR = 0x6032 -local OVERHEAT_PROTECTION_ENABLE_ATTR = 0x6034 -local RADAR_ENABLE_ATTR = 0x6035 - --- Keep in sync with PRIVATE_REFRESH_ATTRIBUTES in sonoff/tp_wgzba/init.lua. -local PRIVATE_REFRESH_ATTRIBUTES = { - CHILD_LOCK_ATTR, - BT_PAIRING_BROADCAST_REQ_ATTR, - OPEN_WINDOW_DETECTION_ATTR, - FROST_PROOF_TEMPERATURE_ATTR, - TEMPORARY_MODE_SETTINGS_ATTR, - RADAR_SENSITIVITY_LEVEL_ATTR, - RADAR_DO_NOT_DISTURB_ENABLE_ATTR, - RADAR_DO_NOT_DISTURB_PERIOD_ATTR, - SCREEN_WORKING_BRIGHTNESS_ATTR, - SCREEN_STANDBY_BRIGHTNESS_ATTR, - SCREEN_NIGHT_STANDBY_BRIGHTNESS_ATTR, - SCREEN_NIGHT_MODE_ENABLE_ATTR, - SCREEN_NIGHT_MODE_PERIOD_ATTR, - RELAY_OUTPUT_TYPE_BITMAP_ATTR, - HVAC_MESSAGE_NOTIFICATION_ATTR, - CURRENT_NTC_TEMPERATURE_RAW_ATTR, - OVERHEAT_PROTECTION_TEMPERATURE_ATTR, - OVERHEAT_PROTECTION_ENABLE_ATTR, - RADAR_ENABLE_ATTR -} - -local mock_device = test.mock_device.build_test_zigbee_device( - { - profile = t_utils.get_profile_definition("thermostat-sonoff.yml"), - zigbee_endpoints = { - [1] = { - id = 1, - manufacturer = "SONOFF", - model = "TP-WGZBA", - server_clusters = { 0x0001, 0x0201, 0xFC11 } - } - } - } -) +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, + bluetoothPairingBroadcast = 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, + screenNightStandbyBrightness = 0, + screenNightMode = false, + screenNightStart = 0, + screenNightEnd = 0, + relayHeatingNormallyClosed = false, + relayBoilerNormallyClosed = false, + overheatProtectionTemperature = 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 expect_main_and_temporary_defaults() - test.socket.capability:__expect_send(mock_device:generate_test_message("main", OpenWindowState.openWindowState("closed"))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", NtcTemperature.ntcTemperature("Not Connected"))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", TemperatureCompensation.temperatureCompensation({ value = 0.0, unit = "℃" }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", ChildLock.childLockState("disabled"))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", BtPairingBroadcastReq.btPairingBroadcastReq("disabled"))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", OpenWindowDetection.openWindowDetection("disabled"))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", FrostProofTemperature.frostProofTemperature({ value = 5.0, unit = "℃" }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", RadarSensitivityLevel.radarSensitivityLevel("high"))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", RadarDoNotDisturbEnable.radarDoNotDisturbEnable("disabled"))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", ScreenWorkingBrightness.screenWorkingBrightness({ value = 5, unit = "level" }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", ScreenStandbyBrightness.screenStandbyBrightness({ value = 0, unit = "level" }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", ScreenNightStandbyBrightness.screenNightStandbyBrightness({ value = 0, unit = "level" }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", ScreenNightModeEnable.screenNightModeEnable("disabled"))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", OverheatProtectionTemperature.overheatProtectionTemperature({ value = 33.5, unit = "℃" }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", OverheatProtectionEnable.overheatProtectionEnable("disabled"))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", RadarEnable.radarEnable("disabled"))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", TempCtrlThreshLow.tempCtrlThreshLow({ value = -2.0, unit = "℃" }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", TempCtrlThreshHigh.tempCtrlThreshHigh({ value = 2.0, unit = "℃" }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", RadarDndStartMin.radarDndStartMin({ value = 0, unit = "min" }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", RadarDndEndMin.radarDndEndMin({ value = 0, unit = "min" }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", ScreenNightStartMin.screenNightStartMin({ value = 0, unit = "min" }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", ScreenNightEndMin.screenNightEndMin({ value = 0, unit = "min" }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", RelayOutTypeR1.relayOneOutputType("normallyOpen"))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", RelayOutTypeR2.relayTwoOutputType("normallyOpen"))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", TemporaryModeStatus.temporaryTemperatureModeSettings("None"))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", TemporaryModeDuration.temporaryModeTimeSet({ value = 30, unit = "min" }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", TemporaryModeTemperature.temporaryModeTempSet({ value = 30.0, unit = "℃" }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", TemporaryModeAction.temporaryModeAction("exit"))) -end - 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() - -- Driver startup always delivers lifecycle init, which seeds UI defaults. - test.socket.capability:__set_channel_ordering("relaxed") - expect_main_and_temporary_defaults() end test.set_test_init_function(test_init) -local function build_cluster_specific_command(cluster, command_id, payload) - local zclh = zcl_messages.ZclHeader({ - cmd = data_types.ZCLCommandId(command_id) - }) - zclh.frame_ctrl:set_cluster_specific() - local addrh = messages.AddressHeader( - zb_const.HUB.ADDR, - zb_const.HUB.ENDPOINT, - mock_device:get_short_address(), - mock_device:get_endpoint(cluster), - zb_const.HA_PROFILE_ID, - cluster - ) +-- 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 = addrh, - body = zcl_messages.ZclMessageBody({ - zcl_header = zclh, - zcl_body = generic_body.GenericBody(payload) - }) + 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 -local function expect_refresh_reads() - test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.LocalTemperature:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.SystemMode:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.ThermostatRunningState:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.LocalTemperatureCalibration:read(mock_device) }) - test.socket.zigbee:__expect_send({ mock_device.id, Basic.attributes.SWBuildID:read(mock_device) }) - for _, attribute_id in ipairs(PRIVATE_REFRESH_ATTRIBUTES) do - test.socket.zigbee:__expect_send({ - mock_device.id, - cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, attribute_id, MFG_CODE) - }) - end -end - --- Builds a minimal device object for can_handle tests. -local function can_handle_device(manufacturer, model) - -- The can_handle modules only depend on these two accessors. - return { - get_manufacturer = function() - return manufacturer - end, - get_model = function() - return model - end - } -end - --- Disables the global lifecycle expectations for direct can_handle tests. -local function noop_test_init() - -- These tests do not register a mock device with the driver socket. -end - -test.register_coroutine_test( - "SONOFF can_handle modules should reject non-matching fingerprints", - function() - local aggregate_can_handle = require "sonoff.can_handle" - local tp_wgzba_can_handle = require "sonoff.tp_wgzba.can_handle" - - local aggregate_rejected = aggregate_can_handle({}, nil, can_handle_device("SONOFF", "OTHER")) - local tp_wgzba_rejected = tp_wgzba_can_handle({}, nil, can_handle_device("OTHER", "TP-WGZBA")) - - assert(not aggregate_rejected) - assert(not tp_wgzba_rejected) - end, - { - test_init = noop_test_init - } -) - -if custom_capabilities_available then - -test.register_coroutine_test( - "SONOFF can_handle modules should match TP-WGZBA", - function() - local aggregate_can_handle = require "sonoff.can_handle" - local tp_wgzba_can_handle = require "sonoff.tp_wgzba.can_handle" - - local aggregate_handled, aggregate_sub_driver = aggregate_can_handle({}, nil, can_handle_device("SONOFF", "TP-WGZBA")) - local tp_wgzba_handled, tp_wgzba_sub_driver = tp_wgzba_can_handle({}, nil, can_handle_device("SONOFF", "TP-WGZBA")) - - assert(aggregate_handled) - assert(aggregate_sub_driver.NAME == "SONOFF Thermostat Handler") - assert(tp_wgzba_handled) - assert(tp_wgzba_sub_driver.NAME == "SONOFF TP-WGZBA Handler") - end, - { - test_init = noop_test_init - } -) - -test.register_coroutine_test( - "Device added should emit supported modes and refresh after init already seeded defaults", - function() - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.thermostatMode.supportedThermostatModes( - { "off", "auto", "heat" }, - { visibility = { displayed = false } } - ) - ) - ) - expect_refresh_reads() - end -) - -test.register_coroutine_test( - "Driver switched should emit supported modes and refresh after init already seeded defaults", - function() - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "driverSwitched" }) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", - capabilities.thermostatMode.supportedThermostatModes( - { "off", "auto", "heat" }, - { visibility = { displayed = false } } - ) - ) - ) - expect_refresh_reads() - end -) - -test.register_coroutine_test( - "Init should not re-emit defaults when capability state is already seeded", - function() - -- Startup init already populated state in test_init; a second init must be a no-op. - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) - end -) - -test.register_message_test( - "Temperature reports using the thermostat cluster should be handled", - { - { - 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" })) - } - } -) - -test.register_message_test( - "Heating setpoint reports should be handled", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, Thermostat.attributes.OccupiedHeatingSetpoint:build_test_attr_report(mock_device, 2100) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 21.0, unit = "C" })) - } - } -) - -test.register_message_test( - "Running state reports using the thermostat cluster should be handled", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, Thermostat.attributes.ThermostatRunningState:build_test_attr_report(mock_device, 1) } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.thermostatOperatingState.thermostatOperatingState("heating")) - } - } -) - -test.register_message_test( - "Basic SWBuildID reports should update the firmware version", - { - { - channel = "zigbee", - direction = "receive", - message = { mock_device.id, Basic.attributes.SWBuildID:build_test_attr_report(mock_device, "1.0.4") } - }, - { - channel = "capability", - direction = "send", - message = mock_device:generate_test_message("main", capabilities.firmwareUpdate.currentVersion({ value = "1.0.4" })) - } - } -) - -test.register_coroutine_test( - "Local temperature calibration reports should emit temperature compensation", - function() - test.socket.zigbee:__queue_receive({ - mock_device.id, - Thermostat.attributes.LocalTemperatureCalibration:build_test_attr_report(mock_device, -15) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", TemperatureCompensation.temperatureCompensation({ value = -1.5, unit = "℃" })) - ) - end -) - -test.register_coroutine_test( - "Temporary mode state reports should emit none/boost/timer from vendor attr 0x6014", - function() - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, SONOFF_PRIVATE_CLUSTER, { - { TEMPORARY_MODE_SETTINGS_ATTR, data_types.Uint8.ID, 0xFF } - }, MFG_CODE) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", TemporaryModeStatus.temporaryTemperatureModeSettings("None")) - ) - - test.wait_for_events() - - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, SONOFF_PRIVATE_CLUSTER, { - { TEMPORARY_MODE_SETTINGS_ATTR, data_types.Uint8.ID, 0x00 } - }, MFG_CODE) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", TemporaryModeStatus.temporaryTemperatureModeSettings("Boost")) - ) - - test.wait_for_events() - - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, SONOFF_PRIVATE_CLUSTER, { - { TEMPORARY_MODE_SETTINGS_ATTR, data_types.Uint8.ID, 0x01 } - }, MFG_CODE) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", TemporaryModeStatus.temporaryTemperatureModeSettings("Timer")) - ) - end -) - -test.register_coroutine_test( - "Child lock reports and capability writes should map through private attr 0x0000", - function() - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, SONOFF_PRIVATE_CLUSTER, { - { CHILD_LOCK_ATTR, data_types.Boolean.ID, true } - }, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", ChildLock.childLockState("enabled"))) - - test.wait_for_events() - - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = ChildLock.ID, component = "main", command = "setChildLockState", args = { "disabled" } } - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, CHILD_LOCK_ATTR, MFG_CODE, data_types.Boolean, false) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", ChildLock.childLockState("disabled"))) - end -) - -test.register_coroutine_test( - "Setting thermostat mode should write SystemMode without readback", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "thermostatMode", command = "auto", args = {}, component = "main" } - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - Thermostat.attributes.SystemMode:write(mock_device, ThermostatSystemMode.AUTO) - }) - - test.wait_for_events() - - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "thermostatMode", command = "heat", args = {}, component = "main" } - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - Thermostat.attributes.SystemMode:write(mock_device, ThermostatSystemMode.HEAT) - }) - - test.wait_for_events() - - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "thermostatMode", command = "off", args = {}, component = "main" } - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - Thermostat.attributes.SystemMode:write(mock_device, ThermostatSystemMode.OFF) - }) - end -) - -test.register_coroutine_test( - "Setting heating setpoint should write OccupiedHeatingSetpoint", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - -- positional for API 57; named_args for newer libs with empty reconstructed arg_lists - { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", args = { 22 }, named_args = { setpoint = 22 } } - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 2200) - }) - end -) - -test.register_coroutine_test( - "Setting heating setpoint should clamp values below the supported range", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", args = { 4 }, named_args = { setpoint = 4 } } - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 500) - }) - end -) - -test.register_coroutine_test( - "Setting heating setpoint should clamp values above the supported range", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "thermostatHeatingSetpoint", component = "main", command = "setHeatingSetpoint", args = { 31 }, named_args = { setpoint = 31 } } - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - Thermostat.attributes.OccupiedHeatingSetpoint:write(mock_device, 3000) - }) - end -) - test.register_coroutine_test( - "Temperature compensation capability should write LocalTemperatureCalibration", + "SONOFF sub-driver should match only TP-WGZBA", function() - test.socket.capability:__queue_receive({ - mock_device.id, - { - capability = TemperatureCompensation.ID, - component = "main", - command = "setTemperatureCompensation", - args = { -1.5 } - } + -- The flattened handler owns the fingerprint without a nested TP-WGZBA dispatcher. + local can_handle = require "sonoff.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, }) - test.socket.zigbee:__expect_send({ - mock_device.id, - Thermostat.attributes.LocalTemperatureCalibration:write(mock_device, -15) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", TemperatureCompensation.temperatureCompensation({ value = -1.5, unit = "℃" })) - ) + assert(not rejected) end ) test.register_message_test( - "Refresh should read standard thermostat attrs and selected private attrs", + "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) } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, Thermostat.attributes.LocalTemperatureCalibration:read(mock_device) } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, Basic.attributes.SWBuildID:read(mock_device) } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, CHILD_LOCK_ATTR, MFG_CODE) } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, BT_PAIRING_BROADCAST_REQ_ATTR, MFG_CODE) } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, OPEN_WINDOW_DETECTION_ATTR, MFG_CODE) } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, FROST_PROOF_TEMPERATURE_ATTR, MFG_CODE) } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, TEMPORARY_MODE_SETTINGS_ATTR, MFG_CODE) } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, RADAR_SENSITIVITY_LEVEL_ATTR, MFG_CODE) } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, RADAR_DO_NOT_DISTURB_ENABLE_ATTR, MFG_CODE) } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, RADAR_DO_NOT_DISTURB_PERIOD_ATTR, MFG_CODE) } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, SCREEN_WORKING_BRIGHTNESS_ATTR, MFG_CODE) } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, SCREEN_STANDBY_BRIGHTNESS_ATTR, MFG_CODE) } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, SCREEN_NIGHT_STANDBY_BRIGHTNESS_ATTR, MFG_CODE) } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, SCREEN_NIGHT_MODE_ENABLE_ATTR, MFG_CODE) } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, SCREEN_NIGHT_MODE_PERIOD_ATTR, MFG_CODE) } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, RELAY_OUTPUT_TYPE_BITMAP_ATTR, MFG_CODE) } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, HVAC_MESSAGE_NOTIFICATION_ATTR, MFG_CODE) } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, CURRENT_NTC_TEMPERATURE_RAW_ATTR, MFG_CODE) } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, OVERHEAT_PROTECTION_TEMPERATURE_ATTR, MFG_CODE) } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, OVERHEAT_PROTECTION_ENABLE_ATTR, MFG_CODE) } - }, - { - channel = "zigbee", - direction = "send", - message = { mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, RADAR_ENABLE_ATTR, MFG_CODE) } - } + { 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) } }, + { channel = "zigbee", direction = "send", message = { mock_device.id, Basic.attributes.SWBuildID:read(mock_device) } }, } ) test.register_coroutine_test( - "Configure should bind Thermostat cluster without configuring reports", + "Configure should bind and enable standard thermostat reporting", function() - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - test.socket.zigbee:__expect_send({ - mock_device.id, - zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Thermostat.ID) - }) + -- 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.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( - "Simple private capability writes should update vendor attributes and emit events", + "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.capability:__set_channel_ordering("relaxed") - - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = OpenWindowDetection.ID, component = "main", command = "setOpenWindowDetection", args = { "enabled" } } - }) + 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, SONOFF_PRIVATE_CLUSTER, OPEN_WINDOW_DETECTION_ATTR, MFG_CODE, data_types.Boolean, true) + 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) })) }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", OpenWindowDetection.openWindowDetection("enabled"))) - - test.wait_for_events() - - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = FrostProofTemperature.ID, component = "main", command = "setFrostProofTemperature", args = { 7.5 } } - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, FROST_PROOF_TEMPERATURE_ATTR, MFG_CODE, data_types.Int16, 750) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", FrostProofTemperature.frostProofTemperature({ value = 7.5, unit = "℃" }))) - - test.wait_for_events() - - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = RadarEnable.ID, component = "main", command = "setRadarEnable", args = { "enabled" } } - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, RADAR_ENABLE_ATTR, MFG_CODE, data_types.Boolean, true) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", RadarEnable.radarEnable("enabled"))) - - test.wait_for_events() - - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = RadarSensitivityLevel.ID, component = "main", command = "setRadarSensitivityLevel", args = { "high" } } - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, RADAR_SENSITIVITY_LEVEL_ATTR, MFG_CODE, data_types.Uint8, 2) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", RadarSensitivityLevel.radarSensitivityLevel("high"))) end ) test.register_coroutine_test( - "Threshold and time-period capability writes should rewrite vendor structures", + "Temporary-mode preference should send the vendor cluster command", function() - test.socket.capability:__set_channel_ordering("relaxed") - - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = TempCtrlThreshHigh.ID, component = "main", command = "setTempCtrlThreshHigh", args = { 1.8 } } - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - cluster_base.write_manufacturer_specific_attribute( - mock_device, - SONOFF_PRIVATE_CLUSTER, - TEMPERATURE_CONTROL_THRESHOLD_ATTR, - MFG_CODE, - data_types.Structure, - data_types.Structure({ - data_types.Int16(-200), - data_types.Int16(180) - }) - ) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", TempCtrlThreshLow.tempCtrlThreshLow({ value = -2.0, unit = "℃" }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", TempCtrlThreshHigh.tempCtrlThreshHigh({ value = 1.8, unit = "℃" }))) - - test.wait_for_events() - - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = RadarDndStartMin.ID, component = "main", command = "setRadarDndStartMin", args = { 65 } } - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - cluster_base.write_manufacturer_specific_attribute( - mock_device, - SONOFF_PRIVATE_CLUSTER, - RADAR_DO_NOT_DISTURB_PERIOD_ATTR, - MFG_CODE, - data_types.Structure, - data_types.Structure({ - data_types.Uint16(60), - data_types.Uint16(0) - }) - ) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", RadarDndStartMin.radarDndStartMin({ value = 60, unit = "min" }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", RadarDndEndMin.radarDndEndMin({ value = 0, unit = "min" }))) - - test.wait_for_events() - - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = ScreenNightEndMin.ID, component = "main", command = "setScreenNightEndMin", args = { 1410 } } - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - cluster_base.write_manufacturer_specific_attribute( - mock_device, - SONOFF_PRIVATE_CLUSTER, - SCREEN_NIGHT_MODE_PERIOD_ATTR, - MFG_CODE, - data_types.Structure, - data_types.Structure({ - data_types.Uint16(0), - data_types.Uint16(1410) - }) - ) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", ScreenNightStartMin.screenNightStartMin({ value = 0, unit = "min" }))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", ScreenNightEndMin.screenNightEndMin({ value = 1410, unit = "min" }))) - end -) - -test.register_coroutine_test( - "Relay capability writes should rewrite the vendor bitmap with Bitmap8", - function() - test.socket.capability:__set_channel_ordering("relaxed") - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = RelayOutTypeR1.ID, component = "main", command = "setRelayOneOutputType", args = { "normallyClosed" } } - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - cluster_base.write_manufacturer_specific_attribute(mock_device, SONOFF_PRIVATE_CLUSTER, RELAY_OUTPUT_TYPE_BITMAP_ATTR, MFG_CODE, data_types.Bitmap8, 1) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", RelayOutTypeR1.relayOneOutputType("normallyClosed"))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", RelayOutTypeR2.relayTwoOutputType("normallyOpen"))) - end -) - -test.register_coroutine_test( - "Temporary mode duration and temperature only update local capability state", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = TemporaryModeDuration.ID, component = "main", command = "setTemporaryModeTimeSet", args = { 45 } } - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", TemporaryModeDuration.temporaryModeTimeSet({ value = 45, unit = "min" })) - ) - - test.wait_for_events() - - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = TemporaryModeTemperature.ID, component = "main", command = "setTemporaryModeTempSet", args = { 28.5 } } - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", TemporaryModeTemperature.temporaryModeTempSet({ value = 28.5, unit = "℃" })) - ) - end -) - -test.register_coroutine_test( - "Temporary mode exit sends vendor command then emits action state", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = TemporaryModeAction.ID, component = "main", command = "setTemporaryModeAction", args = { "exit" } } - }) - test.socket.zigbee:__expect_send({ - mock_device.id, - build_cluster_specific_command(SONOFF_PRIVATE_CLUSTER, TEMPORARY_MODE_COMMAND_ID, "\x00") - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", TemporaryModeAction.temporaryModeAction("exit"))) - end -) - -test.register_coroutine_test( - "Temporary mode boost forces 30C UI temperature and sends vendor payload", - function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = TemporaryModeDuration.ID, component = "main", command = "setTemporaryModeTimeSet", args = { 45 } } - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", TemporaryModeDuration.temporaryModeTimeSet({ value = 45, unit = "min" })) - ) - test.wait_for_events() - - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = TemporaryModeAction.ID, component = "main", command = "setTemporaryModeAction", args = { "boost" } } - }) - -- action, duration seconds LE (45*60=2700 -> 8C 0A 00 00), temperature 30.00C -> B8 0B - test.socket.zigbee:__expect_send({ - mock_device.id, - build_cluster_specific_command(SONOFF_PRIVATE_CLUSTER, TEMPORARY_MODE_COMMAND_ID, "\x01\x8C\x0A\x00\x00\xB8\x0B") - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", TemporaryModeTemperature.temporaryModeTempSet({ value = 30.0, unit = "℃" })) - ) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", TemporaryModeAction.temporaryModeAction("boost"))) + -- 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( - "Temporary mode timer sends vendor payload built from cached duration and temperature", + "Setting thermostat mode should read SystemMode after the write", function() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = TemporaryModeDuration.ID, component = "main", command = "setTemporaryModeTimeSet", args = { 45 } } - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", TemporaryModeDuration.temporaryModeTimeSet({ value = 45, unit = "min" })) - ) + -- Readback provides a reliable app update when the device does not report mode changes. + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({ mock_device.id, { capability = capabilities.thermostatMode.ID, component = "main", command = "heat", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.SystemMode:write(mock_device, Thermostat.attributes.SystemMode.HEAT) }) test.wait_for_events() - - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = TemporaryModeTemperature.ID, component = "main", command = "setTemporaryModeTempSet", args = { 28.5 } } - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", TemporaryModeTemperature.temporaryModeTempSet({ value = 28.5, unit = "℃" })) - ) - test.wait_for_events() - - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = TemporaryModeAction.ID, component = "main", command = "setTemporaryModeAction", args = { "timer" } } - }) - -- action=2, duration 2700s, temperature 28.50C -> 22 0B - test.socket.zigbee:__expect_send({ - mock_device.id, - build_cluster_specific_command(SONOFF_PRIVATE_CLUSTER, TEMPORARY_MODE_COMMAND_ID, "\x02\x8C\x0A\x00\x00\x22\x0B") - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", TemporaryModeAction.temporaryModeAction("timer"))) + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, Thermostat.attributes.SystemMode:read(mock_device) }) end ) -test.register_coroutine_test( - "HVAC message and NTC private reports should update read-only capabilities", - function() - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, SONOFF_PRIVATE_CLUSTER, { - { HVAC_MESSAGE_NOTIFICATION_ATTR, data_types.OctetString.ID, "\x00\x01\x01" } - }, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", OpenWindowState.openWindowState("open"))) - - test.wait_for_events() - - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, SONOFF_PRIVATE_CLUSTER, { - { CURRENT_NTC_TEMPERATURE_RAW_ATTR, data_types.Int16.ID, 2350 } - }, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", NtcTemperature.ntcTemperature("23.50 C"))) - - test.wait_for_events() - - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, SONOFF_PRIVATE_CLUSTER, { - { CURRENT_NTC_TEMPERATURE_RAW_ATTR, data_types.Int16.ID, -32768 } - }, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", NtcTemperature.ntcTemperature("Not Connected"))) - end -) - -test.register_coroutine_test( - "Relay bitmap reports should split into heating and boiler contact capabilities", - function() - test.socket.capability:__set_channel_ordering("relaxed") - test.socket.zigbee:__queue_receive({ - mock_device.id, - zigbee_test_utils.build_attribute_report(mock_device, SONOFF_PRIVATE_CLUSTER, { - { RELAY_OUTPUT_TYPE_BITMAP_ATTR, data_types.Bitmap8.ID, 0x03 } - }, MFG_CODE) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", RelayOutTypeR1.relayOneOutputType("normallyClosed"))) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", RelayOutTypeR2.relayTwoOutputType("normallyClosed"))) - 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.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()) }, + } ) -end - test.run_registered_tests() From 1308157c9aafa1d2c51729bdea6b2d10097d650a Mon Sep 17 00:00:00 2001 From: Jiyon Yu Date: Thu, 8 Oct 2026 11:28:19 +0800 Subject: [PATCH 7/8] Address TP-WGZBA review feedback - Encode boolean preferences backed by Uint8 attributes as 0/1 so the Bluetooth pairing broadcast write is actually sent. - Build the temporary-mode payload with string.pack; keep the frame without a manufacturer code, as required by the device firmware. - Rely on base driver and default handlers for temperature, heating setpoint, thermostat mode, running state, driverSwitched and firmware version. - Emit supported thermostat modes in doConfigure. - Shorten preference names to the platform's 24-character limit. --- .../profiles/thermostat-sonoff.yml | 10 +- .../zigbee-thermostat/src/sonoff/init.lua | 102 +++--------------- .../test/test_sonoff_tp_wgzba_thermostat.lua | 52 ++++++--- 3 files changed, 60 insertions(+), 104 deletions(-) diff --git a/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml index 649556fb54..8446024c50 100644 --- a/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml +++ b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml @@ -51,7 +51,7 @@ preferences: preferenceType: boolean definition: default: false -- name: bluetoothPairingBroadcast +- name: btPairingBroadcast title: Bluetooth pairing broadcast description: Enable Bluetooth pairing broadcasts. required: true @@ -175,7 +175,7 @@ preferences: minimum: 0 maximum: 8 default: 0 -- name: screenNightStandbyBrightness +- name: nightStandbyBrightness title: Screen night standby brightness description: Screen brightness during night standby. required: true @@ -209,21 +209,21 @@ preferences: minimum: 0 maximum: 1410 default: 0 -- name: relayHeatingNormallyClosed +- name: heatRelayNormClosed title: Heating relay normally closed description: Configure the heating relay contact type. required: true preferenceType: boolean definition: default: false -- name: relayBoilerNormallyClosed +- name: boilerRelayNormClosed title: Boiler relay normally closed description: Configure the boiler relay contact type. required: true preferenceType: boolean definition: default: false -- name: overheatProtectionTemperature +- name: overheatProtectTemp title: Overheat protection temperature description: Overheat protection setpoint in degrees Celsius. required: true diff --git a/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua index 54c27138f6..21693eb99a 100644 --- a/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua +++ b/drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua @@ -12,11 +12,9 @@ local ZigbeeConstants = require "st.zigbee.constants" local ZigbeeMessages = require "st.zigbee.messages" local ZigbeeZcl = require "st.zigbee.zcl" -local Basic = clusters.Basic local Thermostat = clusters.Thermostat local ThermostatHeatingSetpoint = capabilities.thermostatHeatingSetpoint local ThermostatMode = capabilities.thermostatMode -local ThermostatOperatingState = capabilities.thermostatOperatingState local SONOFF_MFG_CODE = 0x1286 local SONOFF_PRIVATE_CLUSTER = 0xFC11 @@ -40,24 +38,18 @@ local PRIVATE_ATTR_OVERHEAT_PROTECTION_TEMPERATURE = 0x6032 local PRIVATE_ATTR_OVERHEAT_PROTECTION = 0x6034 local PRIVATE_ATTR_RADAR_ENABLED = 0x6035 -local THERMOSTAT_MODE_MAP = { - [ThermostatMode.thermostatMode.off.NAME] = Thermostat.attributes.SystemMode.OFF, - [ThermostatMode.thermostatMode.auto.NAME] = Thermostat.attributes.SystemMode.AUTO, - [ThermostatMode.thermostatMode.heat.NAME] = Thermostat.attributes.SystemMode.HEAT, -} - local PRIVATE_PREFERENCES = { childLock = { attribute = PRIVATE_ATTR_CHILD_LOCK, data_type = data_types.Boolean }, - bluetoothPairingBroadcast = { attribute = PRIVATE_ATTR_BT_PAIRING_BROADCAST, data_type = data_types.Uint8 }, + 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 }, - screenNightStandbyBrightness = { attribute = PRIVATE_ATTR_SCREEN_NIGHT_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 }, - overheatProtectionTemperature = { attribute = PRIVATE_ATTR_OVERHEAT_PROTECTION_TEMPERATURE, data_type = data_types.Int16, scale = 100 }, + 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 }, } @@ -74,6 +66,10 @@ local function preference_value(value, preference) 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 @@ -112,8 +108,9 @@ local function write_temporary_mode(device) 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.char(action_byte, duration_seconds % 0x100, math.floor(duration_seconds / 0x100) % 0x100, math.floor(duration_seconds / 0x10000) % 0x100, math.floor(duration_seconds / 0x1000000) % 0x100, temperature_centi % 0x100, math.floor(temperature_centi / 0x100) % 0x100) - local endpoint = device:get_endpoint(SONOFF_PRIVATE_CLUSTER) or device.fingerprinted_endpoint_id or 1 + local payload = string.pack(" Date: Thu, 8 Oct 2026 12:15:23 +0800 Subject: [PATCH 8/8] Extend TP-WGZBA temperature calibration range to +/-10 C --- .../profiles/thermostat-sonoff.yml | 4 ++-- .../test/test_sonoff_tp_wgzba_thermostat.lua | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml index 8446024c50..80bd8a5b1d 100644 --- a/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml +++ b/drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff.yml @@ -41,8 +41,8 @@ preferences: required: true preferenceType: number definition: - minimum: -5 - maximum: 5 + minimum: -10 + maximum: 10 default: 0 - name: childLock title: Child lock 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 index 01fd29b6d0..4120311b95 100644 --- 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 @@ -155,6 +155,26 @@ test.register_coroutine_test( 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()