Repository navigation
[WWSTCERT-14011] add SONOFF TRV-ZBT thermostat #3204
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
KKlimczukS
merged 15 commits into
SmartThingsCommunity:main
from
QuAnnan2019:codex/sonoff-trv-zbt-wwst
Oct 8, 2026
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
08cfbc6
feat(zigbee): add SONOFF TRV-ZBT thermostat
QuAnnan2019 5fdbbda
fix(zigbee): support legacy thermostat commands
QuAnnan2019 b2fd424
fix(zigbee): align SONOFF setpoint precision
QuAnnan2019 4d657e2
test(zigbee): cover SONOFF setpoint precision
QuAnnan2019 9208323
fix(zigbee): address SONOFF TRV-ZBT review feedback
QuAnnan2019 ff8fba1
Merge branch 'main' into codex/sonoff-trv-zbt-wwst
QuAnnan2019 766f4e1
fix(zigbee): address SONOFF pairing review feedback
QuAnnan2019 22f4052
fix(zigbee): rely on SONOFF mode reporting
QuAnnan2019 15b79d6
Merge branch 'main' into codex/sonoff-trv-zbt-wwst
QuAnnan2019 cefe59e
fix(zigbee): use default SONOFF battery reporting
QuAnnan2019 967f1a8
refactor(zigbee): use framework battery configuration
QuAnnan2019 02fcb89
Merge branch 'main' into codex/sonoff-trv-zbt-wwst
QuAnnan2019 e92a3e7
Merge branch 'main' into codex/sonoff-trv-zbt-wwst
QuAnnan2019 0f9e95d
fix(zigbee): sync SONOFF TRV-ZBT setpoint limits
QuAnnan2019 d5336ca
Merge branch 'main' into codex/sonoff-trv-zbt-wwst
QuAnnan2019 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
38 changes: 38 additions & 0 deletions
38
drivers/SmartThings/zigbee-thermostat/profiles/thermostat-sonoff-trv-zbt.yml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| name: thermostat-sonoff-trv-zbt | ||
| components: | ||
| - id: main | ||
| capabilities: | ||
| - id: temperatureMeasurement | ||
| version: 1 | ||
| - id: thermostatHeatingSetpoint | ||
| version: 1 | ||
| config: | ||
| values: | ||
| - key: "heatingSetpoint.value" | ||
| range: [5, 30] | ||
| - id: thermostatMode | ||
| version: 1 | ||
| - id: battery | ||
| version: 1 | ||
| - id: refresh | ||
| version: 1 | ||
| categories: | ||
| - name: Thermostat | ||
| preferences: | ||
| - name: "childLock" | ||
| title: "Child lock" | ||
| description: "Enable or disable the child lock of the thermostat." | ||
| required: false | ||
| preferenceType: enumeration | ||
| definition: | ||
| options: | ||
| 0: "Unlock" | ||
| 1: "Lock" | ||
| default: 0 | ||
| - name: "enterBluetoothPairing" | ||
| title: "Enter Bluetooth pairing" | ||
| description: "Enable to put the thermostat into Bluetooth pairing mode. The pairing window is controlled by the thermostat firmware." | ||
| required: false | ||
| preferenceType: boolean | ||
| definition: | ||
| default: false | ||
12 changes: 12 additions & 0 deletions
12
drivers/SmartThings/zigbee-thermostat/src/sonoff/can_handle.lua
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| -- Copyright 2026 SmartThings, Inc. | ||
| -- Licensed under the Apache License, Version 2.0 | ||
|
|
||
| local function can_handle(opts, driver, device) | ||
| if device:get_manufacturer() == "SONOFF" and device:get_model() == "TRV-ZBT" then | ||
| return true, require("sonoff") | ||
| end | ||
|
|
||
| return false | ||
| end | ||
|
|
||
| return can_handle |
321 changes: 321 additions & 0 deletions
321
drivers/SmartThings/zigbee-thermostat/src/sonoff/init.lua
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,321 @@ | ||
| -- Copyright 2026 SmartThings, Inc. | ||
| -- Licensed under the Apache License, Version 2.0 | ||
|
|
||
| local capabilities = require "st.capabilities" | ||
| local clusters = require "st.zigbee.zcl.clusters" | ||
| local cluster_base = require "st.zigbee.cluster_base" | ||
| local data_types = require "st.zigbee.data_types" | ||
| local generic_body = require "st.zigbee.generic_body" | ||
| local messages = require "st.zigbee.messages" | ||
| local zcl_messages = require "st.zigbee.zcl" | ||
| local zb_const = require "st.zigbee.constants" | ||
| local utils = require "st.utils" | ||
|
|
||
| local PowerConfiguration = clusters.PowerConfiguration | ||
| local Thermostat = clusters.Thermostat | ||
|
|
||
| local Battery = capabilities.battery | ||
| local Refresh = capabilities.refresh | ||
| local TemperatureMeasurement = capabilities.temperatureMeasurement | ||
| local ThermostatHeatingSetpoint = capabilities.thermostatHeatingSetpoint | ||
| local ThermostatMode = capabilities.thermostatMode | ||
|
|
||
| local SONOFF_CLUSTER = 0xFC11 | ||
| local CHILD_LOCK_ATTR = 0x0000 | ||
| local WORK_MODE_ATTR = 0x0018 | ||
| local WORK_MODE_OFF = 0x01 | ||
| local WORK_MODE_MANUAL = 0x03 | ||
| local WORK_MODE_SCHEDULE = 0x04 | ||
| local WORK_MODE_TEMP_MANUAL = 0x05 | ||
|
|
||
| local DEFAULT_MIN_SETPOINT = 5 | ||
| local DEFAULT_MAX_SETPOINT = 30 | ||
| local SETPOINT_STEP = 0.5 | ||
| local CHILD_LOCK_PREFERENCE = "childLock" | ||
| local BLUETOOTH_PAIRING_PREFERENCE = "enterBluetoothPairing" | ||
| local MIN_HEAT_SETPOINT_LIMIT_FIELD = "min_heat_setpoint_limit" | ||
| local MAX_HEAT_SETPOINT_LIMIT_FIELD = "max_heat_setpoint_limit" | ||
| local SUPPORTED_MODES = { | ||
| ThermostatMode.thermostatMode.off.NAME, | ||
| ThermostatMode.thermostatMode.heat.NAME, | ||
| ThermostatMode.thermostatMode.auto.NAME, | ||
| } | ||
|
|
||
| local CONFIGURATIONS = { | ||
| { | ||
| cluster = Thermostat.ID, | ||
| attribute = Thermostat.attributes.LocalTemperature.ID, | ||
| minimum_interval = 10, | ||
| maximum_interval = 300, | ||
| data_type = Thermostat.attributes.LocalTemperature.base_type, | ||
| reportable_change = 50, | ||
| }, | ||
| { | ||
| cluster = Thermostat.ID, | ||
| attribute = Thermostat.attributes.OccupiedHeatingSetpoint.ID, | ||
| minimum_interval = 1, | ||
| maximum_interval = 600, | ||
| data_type = Thermostat.attributes.OccupiedHeatingSetpoint.base_type, | ||
| reportable_change = 50, | ||
| }, | ||
| { | ||
| cluster = Thermostat.ID, | ||
| attribute = Thermostat.attributes.SystemMode.ID, | ||
| minimum_interval = 1, | ||
| maximum_interval = 600, | ||
| data_type = Thermostat.attributes.SystemMode.base_type, | ||
| }, | ||
| { | ||
| cluster = Thermostat.ID, | ||
| attribute = Thermostat.attributes.MinHeatSetpointLimit.ID, | ||
| minimum_interval = 30, | ||
| maximum_interval = 21600, | ||
| data_type = Thermostat.attributes.MinHeatSetpointLimit.base_type, | ||
| reportable_change = 50, | ||
| }, | ||
| { | ||
| cluster = Thermostat.ID, | ||
| attribute = Thermostat.attributes.MaxHeatSetpointLimit.ID, | ||
| minimum_interval = 30, | ||
| maximum_interval = 21600, | ||
| data_type = Thermostat.attributes.MaxHeatSetpointLimit.base_type, | ||
| reportable_change = 50, | ||
| }, | ||
| } | ||
|
|
||
| local function get_setpoint_range(device) | ||
| local reported_min = device:get_field(MIN_HEAT_SETPOINT_LIMIT_FIELD) | ||
| local reported_max = device:get_field(MAX_HEAT_SETPOINT_LIMIT_FIELD) | ||
| local min = reported_min and (reported_min / 100) or DEFAULT_MIN_SETPOINT | ||
| local max = reported_max and (reported_max / 100) or DEFAULT_MAX_SETPOINT | ||
|
|
||
| if min > max then | ||
| min = DEFAULT_MIN_SETPOINT | ||
| max = DEFAULT_MAX_SETPOINT | ||
| end | ||
|
|
||
| return min, max | ||
| end | ||
|
|
||
| local function emit_setpoint_range(device) | ||
| local min, max = get_setpoint_range(device) | ||
| device:emit_event(ThermostatHeatingSetpoint.heatingSetpointRange({ | ||
| value = { | ||
| minimum = min, | ||
| maximum = max, | ||
| step = SETPOINT_STEP, | ||
| }, | ||
| unit = "C", | ||
| }, { visibility = { displayed = false } })) | ||
|
KKlimczukS marked this conversation as resolved.
|
||
| end | ||
|
|
||
| local function emit_supported_modes(device) | ||
| device:emit_event(ThermostatMode.supportedThermostatModes(SUPPORTED_MODES, { | ||
| visibility = { displayed = false }, | ||
| })) | ||
| end | ||
|
|
||
| local function emit_mode(device, mode) | ||
| device:emit_event(ThermostatMode.thermostatMode[mode]()) | ||
| end | ||
|
|
||
| local function command_argument(command, name) | ||
| if command.args ~= nil and command.args[name] ~= nil then | ||
| return command.args[name] | ||
| end | ||
|
|
||
| return command.named_args and command.named_args[name] | ||
| end | ||
|
|
||
| local function refresh(_, device) | ||
| local attributes = { | ||
| Thermostat.attributes.LocalTemperature, | ||
| Thermostat.attributes.OccupiedHeatingSetpoint, | ||
| Thermostat.attributes.SystemMode, | ||
| PowerConfiguration.attributes.BatteryPercentageRemaining, | ||
| } | ||
|
|
||
| for _, attribute in ipairs(attributes) do | ||
| device:send(attribute:read(device)) | ||
| end | ||
| end | ||
|
|
||
| local function configure(driver, device) | ||
| device:configure() | ||
| refresh(driver, device) | ||
| device:send(Thermostat.attributes.MinHeatSetpointLimit:read(device)) | ||
| device:send(Thermostat.attributes.MaxHeatSetpointLimit:read(device)) | ||
| end | ||
|
KKlimczukS marked this conversation as resolved.
|
||
|
|
||
| local function added(_, device) | ||
| emit_supported_modes(device) | ||
| emit_setpoint_range(device) | ||
| end | ||
|
|
||
| local function init(driver, device) | ||
| for _, configuration in ipairs(CONFIGURATIONS) do | ||
| device:add_configured_attribute(configuration) | ||
| end | ||
| added(driver, device) | ||
| refresh(driver, device) | ||
| end | ||
|
|
||
| local function info_changed(_, device, _, args) | ||
| local old_preferences = args.old_st_store and args.old_st_store.preferences or {} | ||
| local child_lock = device.preferences[CHILD_LOCK_PREFERENCE] | ||
| if child_lock ~= nil and old_preferences[CHILD_LOCK_PREFERENCE] ~= child_lock then | ||
| device:send(cluster_base.write_attribute( | ||
| device, | ||
| data_types.ClusterId(SONOFF_CLUSTER), | ||
| data_types.AttributeId(CHILD_LOCK_ATTR), | ||
| data_types.validate_or_build_type(tonumber(child_lock) == 1, data_types.Boolean, "payload") | ||
| )) | ||
| end | ||
|
|
||
| local bluetooth_pairing = device.preferences[BLUETOOTH_PAIRING_PREFERENCE] | ||
| if bluetooth_pairing == true and old_preferences[BLUETOOTH_PAIRING_PREFERENCE] ~= true then | ||
| local zcl_header = zcl_messages.ZclHeader({ | ||
| cmd = data_types.ZCLCommandId(0x10), | ||
| }) | ||
| zcl_header.frame_ctrl:set_cluster_specific() | ||
| device:send(messages.ZigbeeMessageTx({ | ||
| address_header = messages.AddressHeader( | ||
| zb_const.HUB.ADDR, | ||
| zb_const.HUB.ENDPOINT, | ||
| device:get_short_address(), | ||
| device:get_endpoint(SONOFF_CLUSTER) or 1, | ||
| zb_const.HA_PROFILE_ID, | ||
| SONOFF_CLUSTER | ||
| ), | ||
| body = zcl_messages.ZclMessageBody({ | ||
| zcl_header = zcl_header, | ||
| zcl_body = generic_body.GenericBody(string.char(0x03, 0x01, 0x01)), | ||
| }), | ||
| })) | ||
| end | ||
| end | ||
|
|
||
| local function local_temperature_handler(_, device, value) | ||
| if value.value ~= 0x8000 and value.value ~= -32768 then | ||
| device:emit_event(TemperatureMeasurement.temperature({ value = value.value / 100, unit = "C" })) | ||
| end | ||
| end | ||
|
|
||
| local function heating_setpoint_handler(_, device, value) | ||
| if value.value ~= 0x8000 and value.value ~= -32768 then | ||
| device:emit_event(ThermostatHeatingSetpoint.heatingSetpoint({ value = value.value / 100, unit = "C" })) | ||
| end | ||
| end | ||
|
|
||
| local function setpoint_limit_handler(field) | ||
| return function(_, device, value) | ||
| if value.value ~= 0x8000 and value.value ~= -32768 then | ||
| device:set_field(field, value.value, { persist = true }) | ||
| emit_setpoint_range(device) | ||
| end | ||
| end | ||
| end | ||
|
|
||
| local function system_mode_handler(_, device, value) | ||
| if value.value == Thermostat.attributes.SystemMode.OFF then | ||
| emit_mode(device, ThermostatMode.thermostatMode.off.NAME) | ||
| elseif value.value == Thermostat.attributes.SystemMode.AUTO then | ||
| emit_mode(device, ThermostatMode.thermostatMode.auto.NAME) | ||
| elseif value.value == Thermostat.attributes.SystemMode.HEAT then | ||
| emit_mode(device, ThermostatMode.thermostatMode.heat.NAME) | ||
| end | ||
| end | ||
|
|
||
| local function battery_handler(_, device, value) | ||
| device:emit_event(Battery.battery(utils.clamp_value(utils.round(value.value / 2), 0, 100))) | ||
| end | ||
|
|
||
| local function work_mode_handler(_, device, value) | ||
| if value.value == WORK_MODE_OFF then | ||
| emit_mode(device, ThermostatMode.thermostatMode.off.NAME) | ||
| elseif value.value == WORK_MODE_SCHEDULE then | ||
| emit_mode(device, ThermostatMode.thermostatMode.auto.NAME) | ||
| elseif value.value == WORK_MODE_MANUAL or value.value == WORK_MODE_TEMP_MANUAL then | ||
| emit_mode(device, ThermostatMode.thermostatMode.heat.NAME) | ||
| end | ||
| end | ||
|
|
||
| local function set_heating_setpoint(_, device, command) | ||
| local setpoint = command_argument(command, "setpoint") | ||
| if setpoint >= 40 then | ||
| setpoint = utils.f_to_c(setpoint) | ||
| end | ||
|
|
||
| local min, max = get_setpoint_range(device) | ||
| setpoint = utils.clamp_value(setpoint, min, max) | ||
| -- The TRV-ZBT persists heating setpoints in 0.5 C increments. | ||
| setpoint = math.floor((setpoint / SETPOINT_STEP) + 0.000001) * SETPOINT_STEP | ||
| device:send(Thermostat.attributes.OccupiedHeatingSetpoint:write(device, utils.round(setpoint * 100))) | ||
| device:emit_event(ThermostatHeatingSetpoint.heatingSetpoint({ value = setpoint, unit = "C" })) | ||
| end | ||
|
|
||
| local function set_mode(_, device, command) | ||
| local mode_to_system_mode = { | ||
| [ThermostatMode.thermostatMode.off.NAME] = Thermostat.attributes.SystemMode.OFF, | ||
| [ThermostatMode.thermostatMode.heat.NAME] = Thermostat.attributes.SystemMode.HEAT, | ||
| [ThermostatMode.thermostatMode.auto.NAME] = Thermostat.attributes.SystemMode.AUTO, | ||
| } | ||
| local mode = command_argument(command, "mode") | ||
| local system_mode = mode_to_system_mode[mode] | ||
| if system_mode == nil then | ||
| return | ||
| end | ||
|
|
||
| device:send(Thermostat.attributes.SystemMode:write(device, system_mode)) | ||
| emit_mode(device, mode) | ||
| end | ||
|
|
||
| local function mode_setter(mode) | ||
| return function(driver, device, command) | ||
| set_mode(driver, device, { args = { mode = mode } }) | ||
| end | ||
| end | ||
|
|
||
| local sonoff = { | ||
| NAME = "SONOFF TRV-ZBT", | ||
| capability_handlers = { | ||
| [Refresh.ID] = { | ||
| [Refresh.commands.refresh.NAME] = refresh, | ||
| }, | ||
| [ThermostatHeatingSetpoint.ID] = { | ||
| [ThermostatHeatingSetpoint.commands.setHeatingSetpoint.NAME] = set_heating_setpoint, | ||
| }, | ||
| [ThermostatMode.ID] = { | ||
| [ThermostatMode.commands.setThermostatMode.NAME] = set_mode, | ||
| [ThermostatMode.commands.off.NAME] = mode_setter(ThermostatMode.thermostatMode.off.NAME), | ||
| [ThermostatMode.commands.heat.NAME] = mode_setter(ThermostatMode.thermostatMode.heat.NAME), | ||
| [ThermostatMode.commands.auto.NAME] = mode_setter(ThermostatMode.thermostatMode.auto.NAME), | ||
| }, | ||
| }, | ||
| zigbee_handlers = { | ||
| attr = { | ||
| [PowerConfiguration.ID] = { | ||
| [PowerConfiguration.attributes.BatteryPercentageRemaining.ID] = battery_handler, | ||
| }, | ||
| [Thermostat.ID] = { | ||
| [Thermostat.attributes.LocalTemperature.ID] = local_temperature_handler, | ||
| [Thermostat.attributes.OccupiedHeatingSetpoint.ID] = heating_setpoint_handler, | ||
| [Thermostat.attributes.MinHeatSetpointLimit.ID] = setpoint_limit_handler(MIN_HEAT_SETPOINT_LIMIT_FIELD), | ||
| [Thermostat.attributes.MaxHeatSetpointLimit.ID] = setpoint_limit_handler(MAX_HEAT_SETPOINT_LIMIT_FIELD), | ||
| [Thermostat.attributes.SystemMode.ID] = system_mode_handler, | ||
| }, | ||
| [SONOFF_CLUSTER] = { | ||
| [WORK_MODE_ATTR] = work_mode_handler, | ||
| }, | ||
| }, | ||
| }, | ||
| lifecycle_handlers = { | ||
| added = added, | ||
| init = init, | ||
| doConfigure = configure, | ||
| infoChanged = info_changed, | ||
| }, | ||
| can_handle = require "sonoff.can_handle", | ||
| } | ||
|
|
||
| return sonoff | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.