From 389a5c7688a447222d428cb191f3c4652a6134f5 Mon Sep 17 00:00:00 2001 From: haedo-doo Date: Wed, 21 Oct 2026 08:24:56 +0900 Subject: [PATCH 1/5] Aqara Smart LED Strip Driver --- .../zigbee-switch/fingerprints.yml | 5 + .../profiles/aqara-lightstrip-cct.yml | 71 ++++ .../profiles/aqara-lightstrip-rgbw.yml | 33 ++ .../src/aqara-strip/can_handle.lua | 16 + .../zigbee-switch/src/aqara-strip/init.lua | 387 ++++++++++++++++++ .../zigbee-switch/src/sub_drivers.lua | 1 + .../src/test/test_aqara_strip.lua | 384 +++++++++++++++++ 7 files changed, 897 insertions(+) create mode 100644 drivers/SmartThings/zigbee-switch/profiles/aqara-lightstrip-cct.yml create mode 100644 drivers/SmartThings/zigbee-switch/profiles/aqara-lightstrip-rgbw.yml create mode 100644 drivers/SmartThings/zigbee-switch/src/aqara-strip/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/aqara-strip/init.lua create mode 100644 drivers/SmartThings/zigbee-switch/src/test/test_aqara_strip.lua diff --git a/drivers/SmartThings/zigbee-switch/fingerprints.yml b/drivers/SmartThings/zigbee-switch/fingerprints.yml index 613c471dcb..b06cc68988 100644 --- a/drivers/SmartThings/zigbee-switch/fingerprints.yml +++ b/drivers/SmartThings/zigbee-switch/fingerprints.yml @@ -126,6 +126,11 @@ zigbeeManufacturer: manufacturer: LUMI model: lumi.light.cwacn1 deviceProfileName: aqara-light + - id: "LUMI/lumi.dimmer.rcbac1" + deviceLabel: Aqara Smart LED Strip Driver + manufacturer: LUMI + model: lumi.dimmer.rcbac1 + deviceProfileName: aqara-lightstrip-rgbw - id: "Aqara/lumi.light.acn014" deviceLabel: Aqara LED Light Bulb T1 (Tunable White) manufacturer: Aqara diff --git a/drivers/SmartThings/zigbee-switch/profiles/aqara-lightstrip-cct.yml b/drivers/SmartThings/zigbee-switch/profiles/aqara-lightstrip-cct.yml new file mode 100644 index 0000000000..ccdb7afc2b --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/aqara-lightstrip-cct.yml @@ -0,0 +1,71 @@ +name: aqara-lightstrip-cct +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + config: + values: + - key: "level.value" + range: [1, 100] + - id: colorTemperature + version: 1 + config: + values: + - key: "colorTemperature.value" + range: [2700, 6500] + - id: refresh + version: 1 + categories: + - name: Light +- id: lamp1 + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + config: + values: + - key: "level.value" + range: [1, 100] + - id: colorTemperature + version: 1 + config: + values: + - key: "colorTemperature.value" + range: [2700, 6500] + categories: + - name: Light +- id: lamp2 + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + config: + values: + - key: "level.value" + range: [1, 100] + - id: colorTemperature + version: 1 + config: + values: + - key: "colorTemperature.value" + range: [2700, 6500] + categories: + - name: Light +- id: mode + capabilities: + - id: mode + version: 1 + categories: + - name: Light +preferences: + - preferenceId: stse.restorePowerState + explicit: true + - preferenceId: stse.lightFadeInTimeInSec + explicit: true + - preferenceId: stse.lightFadeOutTimeInSec + explicit: true diff --git a/drivers/SmartThings/zigbee-switch/profiles/aqara-lightstrip-rgbw.yml b/drivers/SmartThings/zigbee-switch/profiles/aqara-lightstrip-rgbw.yml new file mode 100644 index 0000000000..5ccfdde1e0 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/profiles/aqara-lightstrip-rgbw.yml @@ -0,0 +1,33 @@ +name: aqara-lightstrip-rgbw +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + config: + values: + - key: "level.value" + range: [1, 100] + - id: colorTemperature + version: 1 + - id: colorControl + version: 1 + - id: refresh + version: 1 + categories: + - name: Light +- id: mode + capabilities: + - id: mode + version: 1 + categories: + - name: Light +preferences: + - preferenceId: stse.restorePowerState + explicit: true + - preferenceId: stse.lightFadeInTimeInSec + explicit: true + - preferenceId: stse.lightFadeOutTimeInSec + explicit: true diff --git a/drivers/SmartThings/zigbee-switch/src/aqara-strip/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/aqara-strip/can_handle.lua new file mode 100644 index 0000000000..9cd1c63b6e --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/aqara-strip/can_handle.lua @@ -0,0 +1,16 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return function(opts, driver, device) + local FINGERPRINTS = { + { mfr = "LUMI", model = "lumi.dimmer.rcbac1" } + } + + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then + local subdriver = require("aqara-strip") + return true, subdriver + end + end + return false +end diff --git a/drivers/SmartThings/zigbee-switch/src/aqara-strip/init.lua b/drivers/SmartThings/zigbee-switch/src/aqara-strip/init.lua new file mode 100644 index 0000000000..bfc58f3a07 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/aqara-strip/init.lua @@ -0,0 +1,387 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local zcl_global_commands = require "st.zigbee.zcl.global_commands" +local switch_defaults = require "st.zigbee.defaults.switch_defaults" +local data_types = require "st.zigbee.data_types" +local device_management = require "st.zigbee.device_management" +local capabilities = require "st.capabilities" +local utils = require "st.utils" +local OnOff = clusters.OnOff +local ColorControl = clusters.ColorControl +local Level = clusters.Level +local log = require "log" + +local PRI_CLU = 0xFCC0 +local PRI_ATTR = 0x0009 +local MFG_CODE = 0x115F +local OP_MODE_ATTR = 0x0509 +local COMPONENT_MAIN = "main" +local COMPONENT_LAMP1 = "lamp1" +local COMPONENT_LAMP2 = "lamp2" +local COMPONENT_MODE = "mode" +local SUPPORTED_MODES = { "rgbw", "cct" } +local SUPPORTED_MODES_CODE = { 0x003, 0x0001 } +local COMPONENT_INFO_TABLE = { + { name = COMPONENT_MAIN, supportMode = SUPPORTED_MODES[1], endPoint = 1, numOfReceiver = 1 }, + { name = COMPONENT_MAIN, supportMode = SUPPORTED_MODES[2], endPoint = 1, numOfReceiver = 2 }, + { name = COMPONENT_LAMP1, supportMode = SUPPORTED_MODES[2], endPoint = 1, numOfReceiver = 1 }, + { name = COMPONENT_LAMP2, supportMode = SUPPORTED_MODES[2], endPoint = 2, numOfReceiver = 1 } +} +local MODE_INFO_TABLE = { + { name = SUPPORTED_MODES[1], code = SUPPORTED_MODES_CODE[1], profileName = "aqara-lightstrip-rgbw" }, + { name = SUPPORTED_MODES[2], code = SUPPORTED_MODES_CODE[2], profileName = "aqara-lightstrip-cct" } +} +local CONVERSION_CONSTANT = 1000000 +local CURRENT_X = "current_x_value" -- y value from xyY color space +local CURRENT_Y = "current_y_value" -- x value from xyY color space +local Y_TRISTIMULUS_VALUE = "y_tristimulus_value" -- Y tristimulus value which is used to convert color xyY -> RGB -> HSV +local HUESAT_TIMER = "huesat_timer" +local TARGET_HUE = "target_hue" +local TARGET_SAT = "target_sat" +local MODE_CHANGE_STABILIZE_DELAY_SEC = 10 + +local function isCCTmode(device) + local lastMode = device:get_latest_state(COMPONENT_MODE, capabilities.mode.ID, capabilities.mode.mode.NAME) or 0 + local ret = true + if lastMode == SUPPORTED_MODES[1] then ret = false end + return ret +end + +local function store_xyY_values(device, x, y, Y) + device:set_field(Y_TRISTIMULUS_VALUE, Y) + device:set_field(CURRENT_X, x) + device:set_field(CURRENT_Y, y) +end + +local query_device = function(device) + return function() + device:send(ColorControl.attributes.CurrentX:read(device)) + device:send(ColorControl.attributes.CurrentY:read(device)) + end +end + +local function switch_on_handler(driver, device, cmd) + local curMode = SUPPORTED_MODES[1] + local cmdComponent = cmd.component + + if isCCTmode(device) then curMode = SUPPORTED_MODES[2] end + for _, i in ipairs(COMPONENT_INFO_TABLE) do + if i.supportMode == curMode and i.name == cmdComponent then + for j = 0, i.numOfReceiver - 1 do + device:send(OnOff.commands.On(device):to_endpoint(i.endPoint + j)) + end + end + end +end + +local function switch_off_handler(driver, device, cmd) + local curMode = SUPPORTED_MODES[1] + local cmdComponent = cmd.component + + if isCCTmode(device) then curMode = SUPPORTED_MODES[2] end + for _, i in ipairs(COMPONENT_INFO_TABLE) do + if i.supportMode == curMode and i.name == cmdComponent then + for j = 0, i.numOfReceiver - 1 do + device:send(OnOff.commands.Off(device):to_endpoint(i.endPoint + j)) + end + end + end +end + +local function set_level_handler(driver, device, cmd) + local curMode = SUPPORTED_MODES[1] + local cmdComponent = cmd.component + local level = math.floor(cmd.args.level / 100.0 * 254) + + if isCCTmode(device) then curMode = SUPPORTED_MODES[2] end + for _, i in ipairs(COMPONENT_INFO_TABLE) do + if i.supportMode == curMode and i.name == cmdComponent then + for j = 0, i.numOfReceiver - 1 do + device:send(Level.commands.MoveToLevelWithOnOff(device, level):to_endpoint(i.endPoint + j)) + end + end + end +end + +local function set_color_temp_handler(driver, device, cmd) + local curMode = SUPPORTED_MODES[1] + local cmdComponent = cmd.component + local temp_in_mired = utils.round(CONVERSION_CONSTANT / cmd.args.temperature) + + if isCCTmode(device) then curMode = SUPPORTED_MODES[2] end + for _, i in ipairs(COMPONENT_INFO_TABLE) do + if i.supportMode == curMode and i.name == cmdComponent then + for j = 0, i.numOfReceiver - 1 do + device:send(ColorControl.commands.MoveToColorTemperature(device, temp_in_mired):to_endpoint(i.endPoint + j)) + end + end + end +end + +local function set_color_handler(driver, device, cmd) + -- Cancel the hue/sat timer if it's running, since setColor includes both hue and saturation + local huesat_timer = device:get_field(HUESAT_TIMER) + if huesat_timer ~= nil then + device.thread:cancel_timer(huesat_timer) + device:set_field(HUESAT_TIMER, nil) + end + + local hue = (cmd.args.color.hue ~= nil and cmd.args.color.hue > 99) and 99 or cmd.args.color.hue + local sat = cmd.args.color.saturation + + local x, y, Y = utils.safe_hsv_to_xy(hue, sat) + store_xyY_values(device, x, y, Y) + switch_defaults.on(driver, device, cmd) + + device:send(ColorControl.commands.MoveToColor(device, x, y, 0x0000)) + + device:set_field(TARGET_HUE, nil) + device:set_field(TARGET_SAT, nil) + device.thread:call_with_delay(2, query_device(device)) +end + +local function onoff_handler(driver, device, value, zb_rx) + local curMode = SUPPORTED_MODES[1] + local srcEndpoint = zb_rx.address_header.src_endpoint.value + local evt = capabilities.switch.switch.off() + if value.value then evt = capabilities.switch.switch.on() end + + if isCCTmode(device) then curMode = SUPPORTED_MODES[2] end + for _, i in ipairs(COMPONENT_INFO_TABLE) do + if i.supportMode == curMode and i.endPoint == srcEndpoint then + device:emit_component_event(device.profile.components[i.name], evt) + end + end +end + +local function current_level_handler(driver, device, value, zb_rx) + local curMode = SUPPORTED_MODES[1] + local srcEndpoint = zb_rx.address_header.src_endpoint.value + local level = math.floor((value.value / 254.0 * 100) + 0.5) + local evt = capabilities.switchLevel.level(level) + + if isCCTmode(device) then curMode = SUPPORTED_MODES[2] end + for _, i in ipairs(COMPONENT_INFO_TABLE) do + if i.supportMode == curMode and i.endPoint == srcEndpoint then + device:emit_component_event(device.profile.components[i.name], evt) + end + end +end + +local function current_color_temp_mireds_handler(driver, device, value, zb_rx) + local srcEndpoint = zb_rx.address_header.src_endpoint.value + local mired = utils.clamp_value(utils.round(CONVERSION_CONSTANT / value.value), 2700, 6500) + local evt = capabilities.colorTemperature.colorTemperature(mired) + + local curMode = SUPPORTED_MODES[1] + + if isCCTmode(device) then curMode = SUPPORTED_MODES[2] end + for _, i in ipairs(COMPONENT_INFO_TABLE) do + if i.supportMode == curMode and i.endPoint == srcEndpoint then + device:emit_component_event(device.profile.components[i.name], evt) + end + end +end + +local function current_x_attr_handler(driver, device, value, zb_rx) + local Y_tristimulus = device:get_field(Y_TRISTIMULUS_VALUE) + local y = device:get_field(CURRENT_Y) + local x = value.value + + if y then + local hue, saturation = utils.safe_xy_to_hsv(x, y, Y_tristimulus) + + device:emit_component_event(device.profile.components[COMPONENT_MAIN], capabilities.colorControl.hue(hue)) + device:emit_component_event(device.profile.components[COMPONENT_MAIN], capabilities.colorControl.saturation(saturation)) + end + + device:set_field(CURRENT_X, x) +end + +local function current_y_attr_handler(driver, device, value, zb_rx) + local Y_tristimulus = device:get_field(Y_TRISTIMULUS_VALUE) + local x = device:get_field(CURRENT_X) + local y = value.value + + if x then + local hue, saturation = utils.safe_xy_to_hsv(x, y, Y_tristimulus) + + device:emit_component_event(device.profile.components[COMPONENT_MAIN], capabilities.colorControl.hue(hue)) + device:emit_component_event(device.profile.components[COMPONENT_MAIN], capabilities.colorControl.saturation(saturation)) + end + + device:set_field(CURRENT_Y, y) +end + +local function set_mode_handler(driver, device, command) + local newMode = command.args.mode + + for _, i in ipairs(MODE_INFO_TABLE) do + if newMode == i.name then + device:send(cluster_base.write_manufacturer_specific_attribute(device, + PRI_CLU, OP_MODE_ATTR, MFG_CODE, data_types.Uint32, i.code)) + end + end +end + +local function do_refresh(driver, device) + if isCCTmode(device) then + device:send(OnOff.attributes.OnOff:read(device):to_endpoint(0x01)) + device:send(Level.attributes.CurrentLevel:read(device):to_endpoint(0x01)) + device:send(ColorControl.attributes.ColorTemperatureMireds:read(device):to_endpoint(0x01)) + device:send(OnOff.attributes.OnOff:read(device):to_endpoint(0x02)) + device:send(Level.attributes.CurrentLevel:read(device):to_endpoint(0x02)) + device:send(ColorControl.attributes.ColorTemperatureMireds:read(device):to_endpoint(0x02)) + else + device:send(OnOff.attributes.OnOff:read(device)) + device:send(Level.attributes.CurrentLevel:read(device)) + device:send(ColorControl.attributes.ColorTemperatureMireds:read(device)) + device:send(ColorControl.attributes.CurrentX:read(device)) + device:send(ColorControl.attributes.CurrentY:read(device)) + end +end + +local function default_response_handler(driver, device, zb_rx) + -- no action +end + +local function device_added(driver, device) + device:emit_component_event(device.profile.components[COMPONENT_MODE], + capabilities.mode.supportedModes(SUPPORTED_MODES, { visibility = { displayed = false } })) + -- Set private attribute + device:send(cluster_base.write_manufacturer_specific_attribute(device, + PRI_CLU, PRI_ATTR, MFG_CODE, data_types.Uint8, 1)) + device:send(cluster_base.read_manufacturer_specific_attribute(device, + PRI_CLU, OP_MODE_ATTR, MFG_CODE)) +end + +local function set_restore_powerState(device) + device:send(cluster_base.write_manufacturer_specific_attribute(device, + PRI_CLU, 0x0201, MFG_CODE, data_types.Boolean, device.preferences["stse.restorePowerState"])) +end + +local function set_light_fadeInTimeState(device) + local fadeInTime = device.preferences["stse.lightFadeInTimeInSec"] * 10 + device:send(clusters.Level.attributes.OnTransitionTime:write(device, fadeInTime):to_endpoint(0x01)) + if isCCTmode(device) then + device:send(clusters.Level.attributes.OnTransitionTime:write(device, fadeInTime):to_endpoint(0x02)) + end +end + +local function set_light_fadeOutTimeState(device) + local fadeOutTime = device.preferences["stse.lightFadeOutTimeInSec"] * 10 + device:send(clusters.Level.attributes.OffTransitionTime:write(device, fadeOutTime):to_endpoint(0x01)) + if isCCTmode(device) then + device:send(clusters.Level.attributes.OffTransitionTime:write(device, fadeOutTime):to_endpoint(0x02)) + end +end + +local function device_info_changed(driver, device, event, args) + if device.profile.id ~= args.old_st_store.profile.id then + local mode = isCCTmode(device) and "cct" or "rgbw" + log.info_with({ hub_logs = true }, string.format("profile changed to mode=%s (model=%s)", mode, device:get_model())) + -- The device auto-reboots (~5-8s) when its operating mode (RGB/CCT) changes. + -- Delay these writes so they aren't lost during the reboot window. + device.thread:call_with_delay(MODE_CHANGE_STABILIZE_DELAY_SEC, function() + set_restore_powerState(device) + set_light_fadeInTimeState(device) + set_light_fadeOutTimeState(device) + end) + end + if device.preferences ~= nil then + if device.preferences["stse.restorePowerState"] ~= nil and device.preferences["stse.restorePowerState"] ~= args.old_st_store.preferences["stse.restorePowerState"] then + set_restore_powerState(device) + end + if device.preferences["stse.lightFadeInTimeInSec"] ~= nil and device.preferences["stse.lightFadeInTimeInSec"] ~= args.old_st_store.preferences["stse.lightFadeInTimeInSec"] then + set_light_fadeInTimeState(device) + end + if device.preferences["stse.lightFadeOutTimeInSec"] ~= nil and device.preferences["stse.lightFadeOutTimeInSec"] ~= args.old_st_store.preferences["stse.lightFadeOutTimeInSec"] then + set_light_fadeOutTimeState(device) + end + end +end + +local function do_configure(driver, device) + -- The device auto-reboots (~5-8s) when its operating mode (RGB/CCT) changes. + -- Delay the refresh so we read state after the device has stabilized. + device.thread:call_with_delay(MODE_CHANGE_STABILIZE_DELAY_SEC, function() + do_refresh(driver, device) + end) + device:configure() + device:send(device_management.build_bind_request(device, OnOff.ID, driver.environment_info.hub_zigbee_eui)) + do_refresh(driver, device) +end + +local function change_plugin_mode(device, value) + local modeCode = value + for _, i in ipairs(MODE_INFO_TABLE) do + if modeCode == i.code then + device:emit_component_event(device.profile.components[COMPONENT_MODE], capabilities.mode.mode(i.name)) + device:try_update_metadata({ profile = i.profileName }) + end + end +end + +local function op_mode_handler(driver, device, value) + change_plugin_mode(device, value.value) +end + + +local aqara_lightstrip_driver_handler = { + NAME = "Aqara Lightstrip Driver Handler", + capability_handlers = { + [capabilities.switch.ID] = { + [capabilities.switch.commands.on.NAME] = switch_on_handler, + [capabilities.switch.commands.off.NAME] = switch_off_handler + }, + [capabilities.switchLevel.ID] = { + [capabilities.switchLevel.commands.setLevel.NAME] = set_level_handler + }, + [capabilities.colorTemperature.ID] = { + [capabilities.colorTemperature.commands.setColorTemperature.NAME] = set_color_temp_handler, + }, + [capabilities.colorControl.ID] = { + [capabilities.colorControl.commands.setColor.NAME] = set_color_handler + }, + [capabilities.mode.ID] = { + [capabilities.mode.commands.setMode.NAME] = set_mode_handler + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh + } + }, + zigbee_handlers = { + global = { + [OnOff.ID] = { + [zcl_global_commands.DEFAULT_RESPONSE_ID] = default_response_handler + } + }, + attr = { + [OnOff.ID] = { + [OnOff.attributes.OnOff.ID] = onoff_handler + }, + [Level.ID] = { + [Level.attributes.CurrentLevel.ID] = current_level_handler + }, + [ColorControl.ID] = { + [ColorControl.attributes.ColorTemperatureMireds.ID] = current_color_temp_mireds_handler, + [ColorControl.attributes.CurrentX.ID] = current_x_attr_handler, + [ColorControl.attributes.CurrentY.ID] = current_y_attr_handler + }, + [PRI_CLU] = { + [OP_MODE_ATTR] = op_mode_handler + } + } + }, + lifecycle_handlers = { + added = device_added, + infoChanged = device_info_changed, + doConfigure = do_configure + }, + can_handle = require("aqara-strip.can_handle") +} + +return aqara_lightstrip_driver_handler diff --git a/drivers/SmartThings/zigbee-switch/src/sub_drivers.lua b/drivers/SmartThings/zigbee-switch/src/sub_drivers.lua index 60390d8074..a1fb02a661 100644 --- a/drivers/SmartThings/zigbee-switch/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-switch/src/sub_drivers.lua @@ -9,6 +9,7 @@ return { lazy_load_if_possible("hanssem"), lazy_load_if_possible("aqara"), lazy_load_if_possible("aqara-light"), + lazy_load_if_possible("aqara-strip"), lazy_load_if_possible("ezex"), lazy_load_if_possible("rexense"), lazy_load_if_possible("sinope"), diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_strip.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_strip.lua new file mode 100644 index 0000000000..ded88b5f59 --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_strip.lua @@ -0,0 +1,384 @@ +-- Copyright 2025 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 t_utils = require "integration_test.utils" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local clusters = require "st.zigbee.zcl.clusters" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local capabilities = require "st.capabilities" +local utils = require "st.utils" +local OnOff = clusters.OnOff +local ColorControl = clusters.ColorControl +local Level = clusters.Level + +local PRI_CLU = 0xFCC0 +local PRI_ATTR = 0x0009 +local MFG_CODE = 0x115F +local OP_MODE_ATTR = 0x0509 +local COMPONENT_MODE = "mode" +local SUPPORTED_MODES = { "rgbw", "cct" } +local CONVERSION_CONSTANT = 1000000 +local CURRENT_X = "current_x_value" +local CURRENT_Y = "current_y_value" +local Y_TRISTIMULUS_VALUE = "y_tristimulus_value" + +local mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("aqara-lightstrip-rgbw.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "LUMI", + model = "lumi.dimmer.rcbac1", + server_clusters = { 0x0006, 0x0008, 0x0300 } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(mock_device) +end + +test.set_test_init_function(test_init) + +-- device_added +test.register_coroutine_test( + "Handle added lifecycle", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + COMPONENT_MODE, + capabilities.mode.supportedModes(SUPPORTED_MODES, { visibility = { displayed = false } }) + ) + ) + test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, + PRI_CLU, PRI_ATTR, MFG_CODE, data_types.Uint8, 1) }) + test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, + PRI_CLU, OP_MODE_ATTR, MFG_CODE)}) + end +) + +-- device_info_changed +test.register_coroutine_test( + "Configure should configure all necessary attributes and refresh device", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 300, 1) + } + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Level.ID) + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Level.attributes.CurrentLevel:configure_reporting(mock_device, 1, 3600, 1) + } + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ColorControl.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:configure_reporting(mock_device, 1, 3600, 16) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.attributes.CurrentSaturation:configure_reporting(mock_device, 1, 3600, 16):to_endpoint(0x01) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.attributes.CurrentHue:configure_reporting(mock_device, 1, 3600, 16):to_endpoint(0x01) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID, 0x01) + }) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device):to_endpoint(0x01) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device):to_endpoint(0x01) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device):to_endpoint(0x01) }) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device):to_endpoint(0x02) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device):to_endpoint(0x02) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device):to_endpoint(0x02) }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end +) + +-- set_restore_powerState +test.register_coroutine_test( + "Handle restorePowerState in infochanged", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { ["stse.restorePowerState"] = true } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRI_CLU, + 0x0201, MFG_CODE, data_types.Boolean, true) }) + end +) + +-- set_light_fadeInTimeState +test.register_coroutine_test( + "Handle lightFadeInTimeInSec in infochanged", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { ["stse.lightFadeInTimeInSec"] = 1 } + })) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.OnTransitionTime:write(mock_device, 10):to_endpoint(0x01) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.OnTransitionTime:write(mock_device, 10):to_endpoint(0x02) }) + end +) +-- set_light_fadeOutTimeState +test.register_coroutine_test( + "Handle lightFadeOutTimeInSec in infochanged", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { ["stse.lightFadeOutTimeInSec"] = 1 } + })) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.OffTransitionTime:write(mock_device, 10):to_endpoint(0x01)}) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.OffTransitionTime:write(mock_device, 10):to_endpoint(0x02)}) + end +) + +-- switch_on_handler +test.register_coroutine_test( + "Switch On handler test", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "main", command = "on", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.On(mock_device):to_endpoint(0x01)}) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.On(mock_device):to_endpoint(0x02)}) + end +) +-- switch_off_handler +test.register_coroutine_test( + "Switch Off handler test", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "main", command = "off", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.Off(mock_device):to_endpoint(0x01)}) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.Off(mock_device):to_endpoint(0x02)}) + end +) + +-- set_level_handler +test.register_coroutine_test( + "Set Level handler test", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switchLevel", component = "main", command = "setLevel", args = { 50 }}}) + local level = math.floor(50 / 100.0 * 254) + test.socket.zigbee:__expect_send({ mock_device.id, + Level.commands.MoveToLevelWithOnOff(mock_device, level):to_endpoint(0x01)}) + test.socket.zigbee:__expect_send({ mock_device.id, + Level.commands.MoveToLevelWithOnOff(mock_device, level):to_endpoint(0x02)}) + end +) + +-- set_color_temp_handler +test.register_coroutine_test( + "Set ColorTemperature handler test", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = { 4000 }}}) + local temp_in_mired = utils.round(1000000 / 4000) + test.socket.zigbee:__expect_send({ mock_device.id, + ColorControl.commands.MoveToColorTemperature(mock_device, temp_in_mired):to_endpoint(0x01)}) + test.socket.zigbee:__expect_send({ mock_device.id, + ColorControl.commands.MoveToColorTemperature(mock_device, temp_in_mired):to_endpoint(0x02)}) + end +) + +-- set_color_handler +test.register_coroutine_test( + "Set Color command test", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({mock_device.id, { capability = "colorControl", component = "main", command = "setColor", args = { { hue = 75, saturation = 65 } } } }) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device) }) + + test.socket.zigbee:__expect_send( + { + mock_device.id, + ColorControl.commands.MoveToColor(mock_device, 0x3E51, 0x255D, 0x0000) + } + ) + + test.wait_for_events() + + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) + end +) + +-- set_mode_handler +test.register_coroutine_test( + "Set Mode handler test - RGBW Mode", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "mode", component = "main", command = "setMode", args = { "rgbw" }}}) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRI_CLU, 0x0509, MFG_CODE, data_types.Uint32, 3) }) + end +) +test.register_coroutine_test( + "Set Mode handler test - Color Temperature Mode", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "mode", component = "main", command = "setMode", args = { "cct" }}}) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, PRI_CLU, 0x0509, MFG_CODE, data_types.Uint32, 1) }) + end +) + +-- do_refresh +test.register_coroutine_test( + "Refresh handler test", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {}}}) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device):to_endpoint(0x01) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device):to_endpoint(0x01) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device):to_endpoint(0x01) }) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device):to_endpoint(0x02) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device):to_endpoint(0x02) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device):to_endpoint(0x02) }) + end +) + +-- onoff_handler +test.register_coroutine_test( + "OnOff handler test", + function() + -- Setup: switch to RGBW mode so handler won't try to emit to non-existent lamp1 + local attr_report_data = { { OP_MODE_ATTR, 0x23, 0x03 } } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRI_CLU, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("mode", capabilities.mode.mode("rgbw"))) + mock_device:expect_metadata_update({ profile = "aqara-lightstrip-rgbw" }) + + test.socket.zigbee:__queue_receive({ mock_device.id, OnOff.attributes.OnOff:build_test_attr_report(mock_device, true) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switch.switch.on())) + end +) + +-- current_level_handler +test.register_coroutine_test( + "CurrentLevel handler test", + function() + local attr_report_data = { { OP_MODE_ATTR, 0x23, 0x03 } } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRI_CLU, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("mode", capabilities.mode.mode("rgbw"))) + mock_device:expect_metadata_update({ profile = "aqara-lightstrip-rgbw" }) + + test.socket.zigbee:__queue_receive({mock_device.id, Level.attributes.CurrentLevel:build_test_attr_report(mock_device, math.floor(50 / 100 * 254))}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.switchLevel.level(50))) + end +) + +-- current_color_temp_mireds_handler +test.register_coroutine_test( + "CurrentColor handler test", + function() + local attr_report_data = { { OP_MODE_ATTR, 0x23, 0x03 } } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRI_CLU, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("mode", capabilities.mode.mode("rgbw"))) + mock_device:expect_metadata_update({ profile = "aqara-lightstrip-rgbw" }) + + test.socket.zigbee:__queue_receive({mock_device.id, ColorControl.attributes.ColorTemperatureMireds:build_test_attr_report(mock_device, + utils.clamp_value(utils.round(CONVERSION_CONSTANT / 6500), 2700, 6500))}) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(2700))) + end +) + +-- current_x_attr_handler +test.register_coroutine_test( + "CurrentX attr handler test", + function() + mock_device:set_field(CURRENT_Y, 9565) + mock_device:set_field(Y_TRISTIMULUS_VALUE, 0.23191276511572) + test.socket.zigbee:__queue_receive({ mock_device.id, ColorControl.attributes.CurrentX:build_test_attr_report(mock_device, 15953) }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.hue(75))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.saturation(65))) + end +) + +-- current_y_attr_handler +test.register_coroutine_test( + "CurrentY attr handler test", + function() + mock_device:set_field(CURRENT_X, 15953) + mock_device:set_field(Y_TRISTIMULUS_VALUE, 0.23191276511572) + test.socket.zigbee:__queue_receive({ mock_device.id, ColorControl.attributes.CurrentY:build_test_attr_report(mock_device, 9565) }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.hue(75))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.colorControl.saturation(65))) + end +) + +-- op_mode_handler +test.register_coroutine_test( + "changePluginMode handled", + function() + local attr_report_data = { + { OP_MODE_ATTR, 0x23, 0x03 } + } + test.socket.zigbee:__queue_receive({ + mock_device.id, + zigbee_test_utils.build_attribute_report(mock_device, PRI_CLU, attr_report_data, MFG_CODE) + }) + test.socket.capability:__expect_send(mock_device:generate_test_message("mode", capabilities.mode.mode("rgbw"))) + mock_device:expect_metadata_update({ profile = "aqara-lightstrip-rgbw" }) + end +) + +test.run_registered_tests() From 719404f8c899ece7ea1167caf391b1f74c148a10 Mon Sep 17 00:00:00 2001 From: haedo-doo Date: Wed, 21 Oct 2026 10:13:52 +0900 Subject: [PATCH 2/5] test: fix API version gating thresholds for aqara-strip configure test --- .../src/test/test_aqara_strip.lua | 75 ++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_strip.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_strip.lua index ded88b5f59..db327ebd92 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_strip.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_strip.lua @@ -134,7 +134,80 @@ test.register_coroutine_test( test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device):to_endpoint(0x02) }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) - end + end, + { + max_api_version = 19 + } +) + +test.register_coroutine_test( + "Configure should configure all necessary attributes and refresh device", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + test.socket.zigbee:__set_channel_ordering("relaxed") + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID) + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + OnOff.attributes.OnOff:configure_reporting(mock_device, 0, 300, 1) + } + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, Level.ID) + }) + test.socket.zigbee:__expect_send( + { + mock_device.id, + Level.attributes.CurrentLevel:configure_reporting(mock_device, 1, 3600, 1) + } + ) + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, ColorControl.ID) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:configure_reporting(mock_device, 1, 3600, 16) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.attributes.CurrentSaturation:configure_reporting(mock_device, 1, 3600, 16):to_endpoint(0x01) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.attributes.CurrentHue:configure_reporting(mock_device, 1, 3600, 16):to_endpoint(0x01) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMaxMireds:configure_reporting(mock_device, 1, 43200, 1) + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + ColorControl.attributes.ColorTempPhysicalMinMireds:configure_reporting(mock_device, 1, 43200, 1) + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID, 0x01) + }) + + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device):to_endpoint(0x01) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device):to_endpoint(0x01) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device):to_endpoint(0x01) }) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device):to_endpoint(0x02) }) + test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device):to_endpoint(0x02) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device):to_endpoint(0x02) }) + + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + min_api_version = 20 + } ) -- set_restore_powerState From dde77e0db9d098cc429c41662f3356995532c873 Mon Sep 17 00:00:00 2001 From: haedo-doo Date: Wed, 21 Oct 2026 15:28:19 +0900 Subject: [PATCH 3/5] Aqara LED Strip: handle setHue and setSaturation commands --- .../zigbee-switch/src/aqara-strip/init.lua | 57 ++++++++++++++++++- .../src/test/test_aqara_strip.lua | 50 ++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/drivers/SmartThings/zigbee-switch/src/aqara-strip/init.lua b/drivers/SmartThings/zigbee-switch/src/aqara-strip/init.lua index bfc58f3a07..082db51d22 100644 --- a/drivers/SmartThings/zigbee-switch/src/aqara-strip/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/aqara-strip/init.lua @@ -143,6 +143,59 @@ local function set_color_handler(driver, device, cmd) device.thread:call_with_delay(2, query_device(device)) end +-- The device only accepts MoveToColor (XY); it does not respond to MoveToHue / MoveToSaturation. +-- setHue and setSaturation are therefore debounced: if both arrive within 200 ms (SmartThings +-- color pickers typically emit them together), they are combined into one setColor; otherwise the +-- missing value is filled from the latest known state and setColor is sent once the timer fires. +local huesat_timer_callback = function(driver, device, cmd) + return function() + device:set_field(HUESAT_TIMER, nil) + local hue = device:get_field(TARGET_HUE) + local sat = device:get_field(TARGET_SAT) + hue = hue ~= nil and hue or device:get_latest_state(COMPONENT_MAIN, capabilities.colorControl.ID, capabilities.colorControl.hue.NAME) + sat = sat ~= nil and sat or device:get_latest_state(COMPONENT_MAIN, capabilities.colorControl.ID, capabilities.colorControl.saturation.NAME) + cmd.args = { + color = { + hue = hue, + saturation = sat + } + } + set_color_handler(driver, device, cmd) + end +end + +local function set_hue_sat_helper(driver, device, cmd, hue, sat) + local huesat_timer = device:get_field(HUESAT_TIMER) + if huesat_timer ~= nil then + device.thread:cancel_timer(huesat_timer) + device:set_field(HUESAT_TIMER, nil) + end + if hue ~= nil and sat ~= nil then + cmd.args = { + color = { + hue = hue, + saturation = sat + } + } + set_color_handler(driver, device, cmd) + else + if hue ~= nil then + device:set_field(TARGET_HUE, hue) + elseif sat ~= nil then + device:set_field(TARGET_SAT, sat) + end + device:set_field(HUESAT_TIMER, device.thread:call_with_delay(0.2, huesat_timer_callback(driver, device, cmd))) + end +end + +local function set_hue_handler(driver, device, cmd) + set_hue_sat_helper(driver, device, cmd, cmd.args.hue, device:get_field(TARGET_SAT)) +end + +local function set_saturation_handler(driver, device, cmd) + set_hue_sat_helper(driver, device, cmd, device:get_field(TARGET_HUE), cmd.args.saturation) +end + local function onoff_handler(driver, device, value, zb_rx) local curMode = SUPPORTED_MODES[1] local srcEndpoint = zb_rx.address_header.src_endpoint.value @@ -344,7 +397,9 @@ local aqara_lightstrip_driver_handler = { [capabilities.colorTemperature.commands.setColorTemperature.NAME] = set_color_temp_handler, }, [capabilities.colorControl.ID] = { - [capabilities.colorControl.commands.setColor.NAME] = set_color_handler + [capabilities.colorControl.commands.setColor.NAME] = set_color_handler, + [capabilities.colorControl.commands.setHue.NAME] = set_hue_handler, + [capabilities.colorControl.commands.setSaturation.NAME] = set_saturation_handler }, [capabilities.mode.ID] = { [capabilities.mode.commands.setMode.NAME] = set_mode_handler diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_strip.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_strip.lua index db327ebd92..530dde57cb 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_strip.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_strip.lua @@ -324,6 +324,56 @@ test.register_coroutine_test( end ) +-- set_hue_handler (debounced; sat falls back to 0 when no previous state is known) +test.register_coroutine_test( + "Set Hue handler test", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(0.2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({mock_device.id, + { capability = "colorControl", component = "main", command = "setHue", args = { 75 } } }) + + test.wait_for_events() + test.mock_time.advance_time(0.2) + + local x, y, _ = utils.safe_hsv_to_xy(75, 0) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, + ColorControl.commands.MoveToColor(mock_device, x, y, 0x0000) }) + + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) + end +) + +-- set_saturation_handler (debounced; hue falls back to 0 when no previous state is known) +test.register_coroutine_test( + "Set Saturation handler test", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(0.2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.capability:__queue_receive({mock_device.id, + { capability = "colorControl", component = "main", command = "setSaturation", args = { 65 } } }) + + test.wait_for_events() + test.mock_time.advance_time(0.2) + + local x, y, _ = utils.safe_hsv_to_xy(0, 65) + test.socket.zigbee:__expect_send({ mock_device.id, OnOff.server.commands.On(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, + ColorControl.commands.MoveToColor(mock_device, x, y, 0x0000) }) + + test.wait_for_events() + test.mock_time.advance_time(2) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentX:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.CurrentY:read(mock_device) }) + end +) + -- set_mode_handler test.register_coroutine_test( "Set Mode handler test - RGBW Mode", From f3a85f7085a4cf786e1f9a27a44f13083d5398d0 Mon Sep 17 00:00:00 2001 From: haedo-doo Date: Mon, 26 Oct 2026 13:55:20 +0900 Subject: [PATCH 4/5] Aqara LED Strip Driver: address review feedback --- .../profiles/aqara-lightstrip-rgbw.yml | 4 +++ .../src/aqara-strip/can_handle.lua | 8 ++--- .../src/aqara-strip/fingerprints.lua | 6 ++++ .../zigbee-switch/src/aqara-strip/init.lua | 30 ++++++++++++++----- .../src/test/test_aqara_strip.lua | 12 +------- 5 files changed, 36 insertions(+), 24 deletions(-) create mode 100644 drivers/SmartThings/zigbee-switch/src/aqara-strip/fingerprints.lua diff --git a/drivers/SmartThings/zigbee-switch/profiles/aqara-lightstrip-rgbw.yml b/drivers/SmartThings/zigbee-switch/profiles/aqara-lightstrip-rgbw.yml index 5ccfdde1e0..4601d81c88 100644 --- a/drivers/SmartThings/zigbee-switch/profiles/aqara-lightstrip-rgbw.yml +++ b/drivers/SmartThings/zigbee-switch/profiles/aqara-lightstrip-rgbw.yml @@ -12,6 +12,10 @@ components: range: [1, 100] - id: colorTemperature version: 1 + config: + values: + - key: "colorTemperature.value" + range: [2700, 6500] - id: colorControl version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-switch/src/aqara-strip/can_handle.lua b/drivers/SmartThings/zigbee-switch/src/aqara-strip/can_handle.lua index 9cd1c63b6e..d200623011 100644 --- a/drivers/SmartThings/zigbee-switch/src/aqara-strip/can_handle.lua +++ b/drivers/SmartThings/zigbee-switch/src/aqara-strip/can_handle.lua @@ -1,11 +1,9 @@ --- Copyright 2025 SmartThings, Inc. +-- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 -return function(opts, driver, device) - local FINGERPRINTS = { - { mfr = "LUMI", model = "lumi.dimmer.rcbac1" } - } +local FINGERPRINTS = require("aqara-strip.fingerprints") +return function(opts, driver, device) for _, fingerprint in ipairs(FINGERPRINTS) do if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then local subdriver = require("aqara-strip") diff --git a/drivers/SmartThings/zigbee-switch/src/aqara-strip/fingerprints.lua b/drivers/SmartThings/zigbee-switch/src/aqara-strip/fingerprints.lua new file mode 100644 index 0000000000..7fb4dcc61a --- /dev/null +++ b/drivers/SmartThings/zigbee-switch/src/aqara-strip/fingerprints.lua @@ -0,0 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +return { + { mfr = "LUMI", model = "lumi.dimmer.rcbac1" } +} diff --git a/drivers/SmartThings/zigbee-switch/src/aqara-strip/init.lua b/drivers/SmartThings/zigbee-switch/src/aqara-strip/init.lua index 082db51d22..e726dde95f 100644 --- a/drivers/SmartThings/zigbee-switch/src/aqara-strip/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/aqara-strip/init.lua @@ -1,4 +1,4 @@ --- Copyright 2025 SmartThings, Inc. +-- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 local clusters = require "st.zigbee.zcl.clusters" @@ -6,7 +6,6 @@ local cluster_base = require "st.zigbee.cluster_base" local zcl_global_commands = require "st.zigbee.zcl.global_commands" local switch_defaults = require "st.zigbee.defaults.switch_defaults" local data_types = require "st.zigbee.data_types" -local device_management = require "st.zigbee.device_management" local capabilities = require "st.capabilities" local utils = require "st.utils" local OnOff = clusters.OnOff @@ -129,8 +128,13 @@ local function set_color_handler(driver, device, cmd) device:set_field(HUESAT_TIMER, nil) end - local hue = (cmd.args.color.hue ~= nil and cmd.args.color.hue > 99) and 99 or cmd.args.color.hue + -- A caller may send only one of hue / saturation; fall back to the last known state for the + -- missing component so, e.g., a saturation-only setColor doesn't also reset the hue to zero. + local hue = cmd.args.color.hue + or device:get_latest_state(COMPONENT_MAIN, capabilities.colorControl.ID, capabilities.colorControl.hue.NAME) local sat = cmd.args.color.saturation + or device:get_latest_state(COMPONENT_MAIN, capabilities.colorControl.ID, capabilities.colorControl.saturation.NAME) + if hue ~= nil and hue > 99 then hue = 99 end local x, y, Y = utils.safe_hsv_to_xy(hue, sat) store_xyY_values(device, x, y, Y) @@ -297,8 +301,10 @@ local function do_refresh(driver, device) end end +-- Deliberately empty: suppresses the default OnOff DefaultResponse handler, which would otherwise +-- re-read the OnOff attribute. This device reports OnOff state via attribute reports on its own, +-- so the re-read is unnecessary and causes extra Zigbee traffic. local function default_response_handler(driver, device, zb_rx) - -- no action end local function device_added(driver, device) @@ -312,12 +318,16 @@ local function device_added(driver, device) end local function set_restore_powerState(device) + local pref = device.preferences["stse.restorePowerState"] + if pref == nil then return end device:send(cluster_base.write_manufacturer_specific_attribute(device, - PRI_CLU, 0x0201, MFG_CODE, data_types.Boolean, device.preferences["stse.restorePowerState"])) + PRI_CLU, 0x0201, MFG_CODE, data_types.Boolean, pref)) end local function set_light_fadeInTimeState(device) - local fadeInTime = device.preferences["stse.lightFadeInTimeInSec"] * 10 + local pref = device.preferences["stse.lightFadeInTimeInSec"] + if pref == nil then return end + local fadeInTime = pref * 10 device:send(clusters.Level.attributes.OnTransitionTime:write(device, fadeInTime):to_endpoint(0x01)) if isCCTmode(device) then device:send(clusters.Level.attributes.OnTransitionTime:write(device, fadeInTime):to_endpoint(0x02)) @@ -325,7 +335,9 @@ local function set_light_fadeInTimeState(device) end local function set_light_fadeOutTimeState(device) - local fadeOutTime = device.preferences["stse.lightFadeOutTimeInSec"] * 10 + local pref = device.preferences["stse.lightFadeOutTimeInSec"] + if pref == nil then return end + local fadeOutTime = pref * 10 device:send(clusters.Level.attributes.OffTransitionTime:write(device, fadeOutTime):to_endpoint(0x01)) if isCCTmode(device) then device:send(clusters.Level.attributes.OffTransitionTime:write(device, fadeOutTime):to_endpoint(0x02)) @@ -338,11 +350,14 @@ local function device_info_changed(driver, device, event, args) log.info_with({ hub_logs = true }, string.format("profile changed to mode=%s (model=%s)", mode, device:get_model())) -- The device auto-reboots (~5-8s) when its operating mode (RGB/CCT) changes. -- Delay these writes so they aren't lost during the reboot window. + -- The delayed writes already read the current preference values, so skip the per-preference + -- branch below to avoid sending duplicates (which would also be lost during the reboot window). device.thread:call_with_delay(MODE_CHANGE_STABILIZE_DELAY_SEC, function() set_restore_powerState(device) set_light_fadeInTimeState(device) set_light_fadeOutTimeState(device) end) + return end if device.preferences ~= nil then if device.preferences["stse.restorePowerState"] ~= nil and device.preferences["stse.restorePowerState"] ~= args.old_st_store.preferences["stse.restorePowerState"] then @@ -364,7 +379,6 @@ local function do_configure(driver, device) do_refresh(driver, device) end) device:configure() - device:send(device_management.build_bind_request(device, OnOff.ID, driver.environment_info.hub_zigbee_eui)) do_refresh(driver, device) end diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_strip.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_strip.lua index 530dde57cb..3677a244a4 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_strip.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_strip.lua @@ -1,4 +1,4 @@ --- Copyright 2025 SmartThings +-- 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. @@ -121,11 +121,6 @@ test.register_coroutine_test( ColorControl.attributes.CurrentHue:configure_reporting(mock_device, 1, 3600, 16):to_endpoint(0x01) }) - test.socket.zigbee:__expect_send({ - mock_device.id, - zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID, 0x01) - }) - test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device):to_endpoint(0x01) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device):to_endpoint(0x01) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device):to_endpoint(0x01) }) @@ -191,11 +186,6 @@ test.register_coroutine_test( ColorControl.attributes.ColorTempPhysicalMinMireds:configure_reporting(mock_device, 1, 43200, 1) }) - test.socket.zigbee:__expect_send({ - mock_device.id, - zigbee_test_utils.build_bind_request(mock_device, zigbee_test_utils.mock_hub_eui, OnOff.ID, 0x01) - }) - test.socket.zigbee:__expect_send({ mock_device.id, OnOff.attributes.OnOff:read(mock_device):to_endpoint(0x01) }) test.socket.zigbee:__expect_send({ mock_device.id, Level.attributes.CurrentLevel:read(mock_device):to_endpoint(0x01) }) test.socket.zigbee:__expect_send({ mock_device.id, ColorControl.attributes.ColorTemperatureMireds:read(mock_device):to_endpoint(0x01) }) From 6b55722b0b4a11dc1090f47a1568eccc6d8b8a5e Mon Sep 17 00:00:00 2001 From: haedo-doo Date: Fri, 6 Nov 2026 13:36:02 +0900 Subject: [PATCH 5/5] Aqara LED Strip Driver: fix CCT main state tracking --- .../zigbee-switch/src/aqara-strip/init.lua | 169 +++++++++++++++--- .../src/test/test_aqara_strip.lua | 144 +++++++++++++++ 2 files changed, 293 insertions(+), 20 deletions(-) diff --git a/drivers/SmartThings/zigbee-switch/src/aqara-strip/init.lua b/drivers/SmartThings/zigbee-switch/src/aqara-strip/init.lua index e726dde95f..9adf79f1c0 100644 --- a/drivers/SmartThings/zigbee-switch/src/aqara-strip/init.lua +++ b/drivers/SmartThings/zigbee-switch/src/aqara-strip/init.lua @@ -62,6 +62,48 @@ local query_device = function(device) end end +-- True when the device is currently using the CCT profile (both lamp components present). +local function is_cct_profile(device) + return device.profile.components[COMPONENT_LAMP1] ~= nil + and device.profile.components[COMPONENT_LAMP2] ~= nil +end + +-- Compute main's switch state as the OR of both lamps (unknown lamp state treated as Off) +-- and emit it. Used by both onoff_handler (device report path) and the switch command +-- handlers (optimistic pre-emit path). +local function emit_main_switch_from_lamps(device) + local lamp1On = device:get_latest_state(COMPONENT_LAMP1, capabilities.switch.ID, capabilities.switch.switch.NAME) == "on" + local lamp2On = device:get_latest_state(COMPONENT_LAMP2, capabilities.switch.ID, capabilities.switch.switch.NAME) == "on" + local mainEvt = (lamp1On or lamp2On) + and capabilities.switch.switch.on() + or capabilities.switch.switch.off() + device:emit_component_event(device.profile.components[COMPONENT_MAIN], mainEvt) +end + +-- Pre-emit the expected state for the lamp components affected by a command in CCT mode. +-- This is necessary because endpoint 2 has no configure_reporting set up, so lamp2 state +-- would otherwise stay stale after lamp2 or main commands. We trust the command intent as +-- the authoritative value. A main emit (either OR-aggregated or mirrored) is handled by +-- the caller. +local function pre_emit_cct_lamp_state(device, cmdComponent, evt) + if not is_cct_profile(device) then return false end + local targets + if cmdComponent == COMPONENT_MAIN then + targets = { COMPONENT_LAMP1, COMPONENT_LAMP2 } + elseif cmdComponent == COMPONENT_LAMP1 or cmdComponent == COMPONENT_LAMP2 then + targets = { cmdComponent } + else + return false + end + for _, name in ipairs(targets) do + local component = device.profile.components[name] + if component ~= nil then + device:emit_component_event(component, evt) + end + end + return true +end + local function switch_on_handler(driver, device, cmd) local curMode = SUPPORTED_MODES[1] local cmdComponent = cmd.component @@ -74,6 +116,10 @@ local function switch_on_handler(driver, device, cmd) end end end + + if pre_emit_cct_lamp_state(device, cmdComponent, capabilities.switch.switch.on()) then + emit_main_switch_from_lamps(device) + end end local function switch_off_handler(driver, device, cmd) @@ -88,6 +134,10 @@ local function switch_off_handler(driver, device, cmd) end end end + + if pre_emit_cct_lamp_state(device, cmdComponent, capabilities.switch.switch.off()) then + emit_main_switch_from_lamps(device) + end end local function set_level_handler(driver, device, cmd) @@ -103,6 +153,13 @@ local function set_level_handler(driver, device, cmd) end end end + + -- CCT-only: pre-emit the expected level because endpoint 2 does not report state back. + -- Main is a simple mirror of the commanded value. RGBW path is unchanged. + local evt = capabilities.switchLevel.level(cmd.args.level) + if pre_emit_cct_lamp_state(device, cmdComponent, evt) then + device:emit_component_event(device.profile.components[COMPONENT_MAIN], evt) + end end local function set_color_temp_handler(driver, device, cmd) @@ -118,6 +175,15 @@ local function set_color_temp_handler(driver, device, cmd) end end end + + -- CCT-only: pre-emit the expected color temperature because endpoint 2 does not report back. + -- Clamp to the supported hardware range (2700-6500 K) to match what the device will settle at. + -- RGBW path is unchanged. + local clamped_kelvin = utils.clamp_value(cmd.args.temperature, 2700, 6500) + local evt = capabilities.colorTemperature.colorTemperature(clamped_kelvin) + if pre_emit_cct_lamp_state(device, cmdComponent, evt) then + device:emit_component_event(device.profile.components[COMPONENT_MAIN], evt) + end end local function set_color_handler(driver, device, cmd) @@ -201,31 +267,60 @@ local function set_saturation_handler(driver, device, cmd) end local function onoff_handler(driver, device, value, zb_rx) - local curMode = SUPPORTED_MODES[1] local srcEndpoint = zb_rx.address_header.src_endpoint.value - local evt = capabilities.switch.switch.off() - if value.value then evt = capabilities.switch.switch.on() end + local newOn = value.value + local evt = newOn and capabilities.switch.switch.on() or capabilities.switch.switch.off() - if isCCTmode(device) then curMode = SUPPORTED_MODES[2] end - for _, i in ipairs(COMPONENT_INFO_TABLE) do - if i.supportMode == curMode and i.endPoint == srcEndpoint then - device:emit_component_event(device.profile.components[i.name], evt) - end + if not isCCTmode(device) then + -- RGBW mode: only the fingerprinted endpoint is active, emit to main. + device:emit_component_event(device.profile.components[COMPONENT_MAIN], evt) + return + end + + -- CCT mode: emit to the owning lamp component, then recompute main as + -- "On if either lamp is On". An unknown lamp state is treated as Off. + local lampName + if srcEndpoint == 0x01 then + lampName = COMPONENT_LAMP1 + elseif srcEndpoint == 0x02 then + lampName = COMPONENT_LAMP2 + else + return end + + local lampComponent = device.profile.components[lampName] + if lampComponent == nil then return end + device:emit_component_event(lampComponent, evt) + emit_main_switch_from_lamps(device) end local function current_level_handler(driver, device, value, zb_rx) - local curMode = SUPPORTED_MODES[1] local srcEndpoint = zb_rx.address_header.src_endpoint.value local level = math.floor((value.value / 254.0 * 100) + 0.5) local evt = capabilities.switchLevel.level(level) - if isCCTmode(device) then curMode = SUPPORTED_MODES[2] end - for _, i in ipairs(COMPONENT_INFO_TABLE) do - if i.supportMode == curMode and i.endPoint == srcEndpoint then - device:emit_component_event(device.profile.components[i.name], evt) - end + if not isCCTmode(device) then + -- RGBW mode: only the fingerprinted endpoint is active, emit to main. + device:emit_component_event(device.profile.components[COMPONENT_MAIN], evt) + return end + + -- CCT mode: emit to the owning lamp component and mirror to main so that + -- level reports from both endpoints keep main's value in sync. + local lampName + if srcEndpoint == 0x01 then + lampName = COMPONENT_LAMP1 + elseif srcEndpoint == 0x02 then + lampName = COMPONENT_LAMP2 + else + return + end + + local lampComponent = device.profile.components[lampName] + if lampComponent ~= nil then + device:emit_component_event(lampComponent, evt) + end + device:emit_component_event(device.profile.components[COMPONENT_MAIN], evt) end local function current_color_temp_mireds_handler(driver, device, value, zb_rx) @@ -233,14 +328,28 @@ local function current_color_temp_mireds_handler(driver, device, value, zb_rx) local mired = utils.clamp_value(utils.round(CONVERSION_CONSTANT / value.value), 2700, 6500) local evt = capabilities.colorTemperature.colorTemperature(mired) - local curMode = SUPPORTED_MODES[1] + if not isCCTmode(device) then + -- RGBW mode: only the fingerprinted endpoint is active, emit to main. + device:emit_component_event(device.profile.components[COMPONENT_MAIN], evt) + return + end - if isCCTmode(device) then curMode = SUPPORTED_MODES[2] end - for _, i in ipairs(COMPONENT_INFO_TABLE) do - if i.supportMode == curMode and i.endPoint == srcEndpoint then - device:emit_component_event(device.profile.components[i.name], evt) - end + -- CCT mode: emit to the owning lamp component and mirror to main so that + -- color-temperature reports from both endpoints keep main's value in sync. + local lampName + if srcEndpoint == 0x01 then + lampName = COMPONENT_LAMP1 + elseif srcEndpoint == 0x02 then + lampName = COMPONENT_LAMP2 + else + return end + + local lampComponent = device.profile.components[lampName] + if lampComponent ~= nil then + device:emit_component_event(lampComponent, evt) + end + device:emit_component_event(device.profile.components[COMPONENT_MAIN], evt) end local function current_x_attr_handler(driver, device, value, zb_rx) @@ -307,9 +416,27 @@ end local function default_response_handler(driver, device, zb_rx) end +-- Emit the device's actual color temperature range to every present light component. +-- Without this, the SmartThings capability defaults imply a wider range than the hardware +-- supports (device hardware max is 6500 K), which both widens the app's color-temperature +-- slider past the supported range and causes STTS "max" tests to probe above the device's +-- limit and fail. +local function emit_color_temp_range(device) + local range = capabilities.colorTemperature.colorTemperatureRange( + { minimum = 2700, maximum = 6500 } + ) + for _, componentName in ipairs({ COMPONENT_MAIN, COMPONENT_LAMP1, COMPONENT_LAMP2 }) do + local component = device.profile.components[componentName] + if component ~= nil then + device:emit_component_event(component, range) + end + end +end + local function device_added(driver, device) device:emit_component_event(device.profile.components[COMPONENT_MODE], capabilities.mode.supportedModes(SUPPORTED_MODES, { visibility = { displayed = false } })) + emit_color_temp_range(device) -- Set private attribute device:send(cluster_base.write_manufacturer_specific_attribute(device, PRI_CLU, PRI_ATTR, MFG_CODE, data_types.Uint8, 1)) @@ -348,6 +475,8 @@ local function device_info_changed(driver, device, event, args) if device.profile.id ~= args.old_st_store.profile.id then local mode = isCCTmode(device) and "cct" or "rgbw" log.info_with({ hub_logs = true }, string.format("profile changed to mode=%s (model=%s)", mode, device:get_model())) + -- Re-emit colorTemperatureRange for the newly-available components (lamp1/lamp2 on CCT). + emit_color_temp_range(device) -- The device auto-reboots (~5-8s) when its operating mode (RGB/CCT) changes. -- Delay these writes so they aren't lost during the reboot window. -- The delayed writes already read the current preference values, so skip the per-preference diff --git a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_strip.lua b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_strip.lua index 3677a244a4..1d88cd1548 100644 --- a/drivers/SmartThings/zigbee-switch/src/test/test_aqara_strip.lua +++ b/drivers/SmartThings/zigbee-switch/src/test/test_aqara_strip.lua @@ -51,10 +51,26 @@ local mock_device = test.mock_device.build_test_zigbee_device( } ) +local mock_device_cct = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("aqara-lightstrip-cct.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "LUMI", + model = "lumi.dimmer.rcbac1", + server_clusters = { 0x0006, 0x0008, 0x0300 } + } + } + } +) + zigbee_test_utils.prepare_zigbee_env_info() local function test_init() test.mock_device.add_test_device(mock_device) + test.mock_device.add_test_device(mock_device_cct) end test.set_test_init_function(test_init) @@ -70,6 +86,12 @@ test.register_coroutine_test( capabilities.mode.supportedModes(SUPPORTED_MODES, { visibility = { displayed = false } }) ) ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", + capabilities.colorTemperature.colorTemperatureRange({ minimum = 2700, maximum = 6500 }) + ) + ) test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.write_manufacturer_specific_attribute(mock_device, PRI_CLU, PRI_ATTR, MFG_CODE, data_types.Uint8, 1) }) test.socket.zigbee:__expect_send({ mock_device.id, cluster_base.read_manufacturer_specific_attribute(mock_device, @@ -417,6 +439,128 @@ test.register_coroutine_test( end ) +-- onoff_handler (CCT mode, lamp1 report with lamp2 unknown) +-- Verifies that a lamp1 ON report emits to lamp1 and computes main = On +-- (lamp2 unknown is treated as Off; On OR Off = On). +test.register_coroutine_test( + "OnOff handler test - CCT mode, lamp1 On (lamp2 unknown)", + function() + test.socket.zigbee:__queue_receive({ + mock_device_cct.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_device_cct, true):from_endpoint(0x01) + }) + test.socket.capability:__expect_send(mock_device_cct:generate_test_message("lamp1", capabilities.switch.switch.on())) + test.socket.capability:__expect_send(mock_device_cct:generate_test_message("main", capabilities.switch.switch.on())) + end +) + +-- onoff_handler (CCT mode, lamp1 off while lamp2 is on) +-- This is the exact scenario that caused the STTS "main=On AND lamp1=Off" prerequisite +-- to be unreachable: after this fix, turning off lamp1 must leave main=On when lamp2 is still On. +test.register_coroutine_test( + "OnOff handler test - CCT mode, lamp1 Off while lamp2 is On, main stays On", + function() + -- Step 1: lamp2 ON report (establishes lamp2 = On in device state). + test.socket.zigbee:__queue_receive({ + mock_device_cct.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_device_cct, true):from_endpoint(0x02) + }) + test.socket.capability:__expect_send(mock_device_cct:generate_test_message("lamp2", capabilities.switch.switch.on())) + test.socket.capability:__expect_send(mock_device_cct:generate_test_message("main", capabilities.switch.switch.on())) + + -- Step 2: lamp1 OFF report. main must stay On because lamp2 is still On. + test.socket.zigbee:__queue_receive({ + mock_device_cct.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_device_cct, false):from_endpoint(0x01) + }) + test.socket.capability:__expect_send(mock_device_cct:generate_test_message("lamp1", capabilities.switch.switch.off())) + test.socket.capability:__expect_send(mock_device_cct:generate_test_message("main", capabilities.switch.switch.on())) + end +) + +-- current_level_handler (CCT mode, ep 0x02 report updates lamp2 and main symmetrically) +test.register_coroutine_test( + "CurrentLevel handler test - CCT mode, ep 0x02 updates lamp2 and main", + function() + test.socket.zigbee:__queue_receive({ + mock_device_cct.id, + Level.attributes.CurrentLevel:build_test_attr_report(mock_device_cct, math.floor(50 / 100 * 254)):from_endpoint(0x02) + }) + test.socket.capability:__expect_send(mock_device_cct:generate_test_message("lamp2", capabilities.switchLevel.level(50))) + test.socket.capability:__expect_send(mock_device_cct:generate_test_message("main", capabilities.switchLevel.level(50))) + end +) + +-- current_color_temp_mireds_handler (CCT mode, ep 0x02 report updates lamp2 and main symmetrically) +test.register_coroutine_test( + "CurrentColorTemperature handler test - CCT mode, ep 0x02 updates lamp2 and main", + function() + -- 250 mireds -> 1000000 / 250 = 4000 K (within the clamp range 2700-6500). + test.socket.zigbee:__queue_receive({ + mock_device_cct.id, + ColorControl.attributes.ColorTemperatureMireds:build_test_attr_report(mock_device_cct, 250):from_endpoint(0x02) + }) + test.socket.capability:__expect_send(mock_device_cct:generate_test_message("lamp2", capabilities.colorTemperature.colorTemperature(4000))) + test.socket.capability:__expect_send(mock_device_cct:generate_test_message("main", capabilities.colorTemperature.colorTemperature(4000))) + end +) + +-- Command path test: main switch off in CCT must drive main, lamp1 and lamp2 to Off +-- immediately (endpoint 2 does not report back, so without the pre-emit lamp2 state would +-- stay stale and the OR aggregation would keep main On). This is the exact scenario that +-- failed in STTS main/switch/command_off/2/command. +test.register_coroutine_test( + "Switch Off handler test - CCT mode, main off drives all components Off", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device_cct.id, + { capability = "switch", component = "main", command = "off", args = {} } }) + + test.socket.zigbee:__expect_send({ mock_device_cct.id, + OnOff.server.commands.Off(mock_device_cct):to_endpoint(0x01) }) + test.socket.zigbee:__expect_send({ mock_device_cct.id, + OnOff.server.commands.Off(mock_device_cct):to_endpoint(0x02) }) + + test.socket.capability:__expect_send(mock_device_cct:generate_test_message("lamp1", capabilities.switch.switch.off())) + test.socket.capability:__expect_send(mock_device_cct:generate_test_message("lamp2", capabilities.switch.switch.off())) + test.socket.capability:__expect_send(mock_device_cct:generate_test_message("main", capabilities.switch.switch.off())) + end +) + +-- Command path test: lamp2 setLevel must update lamp2's and main's level even though +-- endpoint 2 never reports back. +test.register_coroutine_test( + "Set Level handler test - CCT mode, lamp2 setLevel updates lamp2 and main", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device_cct.id, + { capability = "switchLevel", component = "lamp2", command = "setLevel", args = { 60 } } }) + + test.socket.zigbee:__expect_send({ mock_device_cct.id, + Level.commands.MoveToLevelWithOnOff(mock_device_cct, math.floor(60 / 100 * 254)):to_endpoint(0x02) }) + + test.socket.capability:__expect_send(mock_device_cct:generate_test_message("lamp2", capabilities.switchLevel.level(60))) + test.socket.capability:__expect_send(mock_device_cct:generate_test_message("main", capabilities.switchLevel.level(60))) + end +) + +-- Command path test: lamp2 setColorTemperature must update lamp2 and main even though +-- endpoint 2 never reports back. The emitted value is clamped to the device's actual range. +test.register_coroutine_test( + "Set ColorTemperature handler test - CCT mode, lamp2 setColorTemperature updates lamp2 and main", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.capability:__queue_receive({ mock_device_cct.id, + { capability = "colorTemperature", component = "lamp2", command = "setColorTemperature", args = { 4000 } } }) + + test.socket.zigbee:__expect_send({ mock_device_cct.id, + ColorControl.commands.MoveToColorTemperature(mock_device_cct, utils.round(CONVERSION_CONSTANT / 4000)):to_endpoint(0x02) }) + + test.socket.capability:__expect_send(mock_device_cct:generate_test_message("lamp2", capabilities.colorTemperature.colorTemperature(4000))) + test.socket.capability:__expect_send(mock_device_cct:generate_test_message("main", capabilities.colorTemperature.colorTemperature(4000))) + end +) + -- current_level_handler test.register_coroutine_test( "CurrentLevel handler test",