From 4818175f216e09a51dc8fed9694c6dc07e3fa32f Mon Sep 17 00:00:00 2001 From: zs Date: Thu, 9 Jul 2026 18:40:40 +0800 Subject: [PATCH 1/2] Add step functionality to Zigbee curtains using Common Handler. --- ...ow-treatment-aqara-roller-shade-rotate.yml | 2 + .../profiles/window-treatment-aqara.yml | 2 + .../profiles/window-treatment-battery.yml | 2 + .../profiles/window-treatment-no-preset.yml | 2 + .../profiles/window-treatment-powerSource.yml | 2 + ...w-treatment-profile-no-firmware-update.yml | 2 + .../profiles/window-treatment-profile.yml | 2 + .../profiles/window-treatment-reverse.yml | 2 + .../src/hanssem/init.lua | 13 +- .../src/stateless_handler/can_handle.lua | 13 + .../src/stateless_handler/fingerprints.lua | 79 +++ .../src/stateless_handler/init.lua | 178 +++++++ .../src/sub_drivers.lua | 1 + ...bee_window_treatment_stateless_handler.lua | 485 ++++++++++++++++++ 14 files changed, 781 insertions(+), 4 deletions(-) create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/fingerprints.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/init.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_stateless_handler.lua diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara-roller-shade-rotate.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara-roller-shade-rotate.yml index c454c4d590..e7f5e8d8af 100644 --- a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara-roller-shade-rotate.yml +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara-roller-shade-rotate.yml @@ -6,6 +6,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: stse.initializedStateWithGuide version: 1 - id: stse.shadeRotateState diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara.yml index 6c2cc967e7..4dc959ef93 100644 --- a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara.yml +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara.yml @@ -6,6 +6,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: stse.deviceInitialization version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-battery.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-battery.yml index be0ae73d76..e9b6178ff8 100644 --- a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-battery.yml +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-battery.yml @@ -8,6 +8,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: battery version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-no-preset.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-no-preset.yml index 7a65434c46..67ba3c186d 100644 --- a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-no-preset.yml +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-no-preset.yml @@ -8,6 +8,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-powerSource.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-powerSource.yml index 7323ee0218..d72dc33308 100644 --- a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-powerSource.yml +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-powerSource.yml @@ -6,6 +6,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: windowShadePreset version: 1 - id: powerSource diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-profile-no-firmware-update.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-profile-no-firmware-update.yml index 8e641e6b42..dfeeed7aa1 100644 --- a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-profile-no-firmware-update.yml +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-profile-no-firmware-update.yml @@ -8,6 +8,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: refresh version: 1 categories: diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-profile.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-profile.yml index b10a5ea101..c9fce872c0 100644 --- a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-profile.yml +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-profile.yml @@ -8,6 +8,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-reverse.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-reverse.yml index 0eb666a5d2..54b4312210 100644 --- a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-reverse.yml +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-reverse.yml @@ -6,6 +6,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: windowShadePreset version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-window-treatment/src/hanssem/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/hanssem/init.lua index 41b4eb1cac..f422dd61ca 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/hanssem/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/hanssem/init.lua @@ -25,8 +25,6 @@ local TUYA_CLUSTER = 0xEF00 local DP_TYPE_VALUE = "\x02" local DP_TYPE_ENUM = "\x04" -local SeqNum = 0 - -------- Send Command Function for Tuya Zigbee device ------------- -- ZigbeeMessageTx: -- Uint16: 0x0000 @@ -45,6 +43,14 @@ local SeqNum = 0 -- ReadAttribute: -- AttributeId: 0x0000 +-- Get next sequence number for Tuya commands (per-device storage to avoid counter conflicts) +local function get_next_tuya_seq_num(device) + local seq = device:get_field("tuya_seq_num") or 0 + seq = (seq + 1) % 65536 + device:set_field("tuya_seq_num", seq) + return seq +end + local function SendCommand(device, DpId, Type, Value) local addrh = Messages.AddressHeader( ZigbeeConstants.HUB.ADDR, -- Source Address @@ -57,8 +63,7 @@ local function SendCommand(device, DpId, Type, Value) local zclh = ZigbeeZcl.ZclHeader({cmd = data_types.ZCLCommandId(0x00)}) zclh.frame_ctrl:set_cluster_specific() -- sets this frame control field to be cluster specific -- Make a payload body - SeqNum = (SeqNum + 1) % 65536 - local strSeqNum = string.pack(">I2", SeqNum) -- Pack the Sequence number to 2 bytes unsigned integer type with big endian. + local strSeqNum = string.pack(">I2", get_next_tuya_seq_num(device)) -- Pack the Sequence number to 2 bytes unsigned integer type with big endian. local LenOfValue = string.pack(">I2",string.len(Value)) -- Pack length of Value to 2 bytes unsigned integer type wiht big endian. local PayloadBody = generic_body.GenericBody(strSeqNum .. DpId .. Type .. LenOfValue .. Value) local MsgBody = ZigbeeZcl.ZclMessageBody({zcl_header = zclh, zcl_body = PayloadBody}) diff --git a/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/can_handle.lua new file mode 100644 index 0000000000..209af9f887 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/can_handle.lua @@ -0,0 +1,13 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" + +return function(opts, driver, device) + local can_handle = device:supports_capability(capabilities.statelessWindowShadeLevelStep) + if can_handle then + local subdriver = require("stateless_handler") + return true, subdriver + end + return false +end diff --git a/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/fingerprints.lua new file mode 100644 index 0000000000..583cf82ef9 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/fingerprints.lua @@ -0,0 +1,79 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Brand-specific configuration for window treatment devices +-- Supports statelessWindowShadeLevelStep capability + +local FINGERPRINTS = { + -- invert_level = true:The level values on the device side and the driver side need to be inverted. + { + name = "aqara", + mfr = "LUMI", + models = { "lumi.curtain", "lumi.curtain.v1", "lumi.curtain.aq2" }, + invert_level = false, + }, + { + name = "vimar", + mfr = "Vimar", + models = { "Window_Cov_v1.0", "Window_Cov_Module_v1.0" }, + invert_level = true, + }, + { + name = "somfy", + mfr = "SOMFY", + models = { "Glydea Ultra Curtain", "Sonesse 30 WF Roller", "Sonesse 40 Roller" }, + invert_level = true, + }, + { + name = "invert-lift-percentage", + mfrs = { "IKEA of Sweden", "Smartwings", "Insta GmbH" }, + models = {}, + invert_level = true, + }, + { + name = "yoolax", + mfrs = { "Yookee", "yooksmart" }, + models = { "D10110" }, + invert_level = true, + }, + -- use_level_cluster = true: Use Level cluster instead of WindowCovering + -- Level cluster uses 0-254 range, converted from percentage: level_value = math.floor(percentage / 100.0 * 254) + { + name = "feibit", + mfr = "Feibit Co.Ltd", + models = { "FTB56-ZT218AK1.6", "FTB56-ZT218AK1.8" }, + use_level_cluster = true, + }, + { + name = "axis", + mfr = "AXIS", + models = {}, + use_level_cluster = true, + }, + -- Standard devices (no special handling needed) + -- Use WindowCovering.GoToLiftPercentage with 0-100 percentage + { + name = "rooms-beautiful", + mfr = "Rooms Beautiful", + models = { "C001" }, + invert_level = true, + }, + { + name = "hanssem", + mfr = "_TZE204_fzbskaga", + models = { "TS0601" }, + use_tuya_cluster = true, + }, + { + name = "screen-innovations", + mfr = "Screen Innovations", + models = { "WM25/L-Z" }, + }, + { + name = "sombra-shades", + mfr = "Sombra Shades", + models = { "SOMBRA/Z-M" }, + }, +} + +return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/init.lua new file mode 100644 index 0000000000..9e37a37bdc --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/init.lua @@ -0,0 +1,178 @@ +-- 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 utils = require "st.utils" +local FINGERPRINTS = require "stateless_handler.fingerprints" +local ZigbeeZcl = require "st.zigbee.zcl" +local Messages = require "st.zigbee.messages" +local data_types = require "st.zigbee.data_types" +local ZigbeeConstants = require "st.zigbee.constants" +local generic_body = require "st.zigbee.generic_body" + +-- Tuya cluster constants (for hanssem) +local TUYA_CLUSTER = 0xEF00 +local DP_TYPE_VALUE = "\x02" +local DP_ID_SET_POSITION = "\x02" + +-- Get next sequence number for Tuya commands (per-device storage to avoid counter conflicts) +local function get_next_tuya_seq_num(device) + local seq = device:get_field("tuya_seq_num") or 0 + seq = (seq + 1) % 65536 + device:set_field("tuya_seq_num", seq) + return seq +end + +-- When the curtain is moving, LATEST_TARGET_LEVEL is used to store the latest target position value, +-- which will be cleared when the curtain status is updated. +local LATEST_TARGET_LEVEL = "_latestTargetLevel" + +-- Get fingerprint for a device +local function get_fingerprint(device) + local manufacturer = device:get_manufacturer() or "" + local model = device:get_model() or "" + + for _, config in ipairs(FINGERPRINTS) do + -- Match manufacturer (case-insensitive) + -- Support both single mfr string and mfrs array + local mfr_match = false + if config.mfrs then + -- Check if manufacturer is in the mfrs array + for _, mfr in ipairs(config.mfrs) do + if string.lower(manufacturer) == string.lower(mfr) then + mfr_match = true + break + end + end + elseif config.mfr then + mfr_match = string.lower(manufacturer) == string.lower(config.mfr) + end + + if mfr_match then + -- If models list is empty, match any model from this manufacturer + if #config.models == 0 then + return config + end + -- Check if model matches any in the list (case-insensitive exact match) + for _, model_pattern in ipairs(config.models) do + if string.lower(model) == string.lower(model_pattern) then + return config + end + end + end + end + return nil -- No matching fingerprint, use default behavior +end + +-- Step shade level handler for statelessWindowShadeLevelStep capability +local function step_shade_level_handler(driver, device, command) + -- Get fingerprint-specific configuration + local fingerprint = get_fingerprint(device) + + -- Support both args.stepSize (named) and args[1] (array) formats + local step = command.args.stepSize or command.args[1] + + if not step or step == 0 then + return + end + + -- When LATEST_TARGET_LEVEL is empty, it means the curtain motor is not moving, + -- and capabilities.windowShadeLevel.shadeLevel is used as the reference for position offset calculation; + -- when LATEST_TARGET_LEVEL is not empty, it means the curtain motor is moving, + -- and LATEST_TARGET_LEVEL is used as the reference for position offset calculation to obtain a more accurate position calculation. + local latest_target_level = device:get_field(LATEST_TARGET_LEVEL) + local current_level = latest_target_level or + device:get_latest_state("main", capabilities.windowShadeLevel.ID, capabilities.windowShadeLevel.shadeLevel.NAME) or 0 + + -- Calculate command level (user's expected percentage) + local command_level = utils.clamp_value(current_level + step, 0, 100) + + command_level = utils.round(command_level) + + -- Apply fingerprint-specific inversion if needed + local device_target_level = command_level + if fingerprint and fingerprint.invert_level then + device_target_level = 100 - command_level + end + + -- Set target_level for tracking (store UI value) + device:set_field(LATEST_TARGET_LEVEL, command_level) + + -- Send command based on fingerprint configuration + if fingerprint and fingerprint.use_level_cluster then + -- Feibit/Axis uses Level cluster + local level_value = math.floor(device_target_level / 100.0 * 254) + device:send_to_component(command.component, clusters.Level.server.commands.MoveToLevelWithOnOff(device, level_value)) + elseif fingerprint and fingerprint.use_tuya_cluster then + -- Hanssem uses Tuya custom cluster 0xEF00 + local strSeqNum = string.pack(">I2", get_next_tuya_seq_num(device)) + local value = string.pack(">I4", device_target_level) + local LenOfValue = string.pack(">I2", string.len(value)) + local PayloadBody = generic_body.GenericBody(strSeqNum .. DP_ID_SET_POSITION .. DP_TYPE_VALUE .. LenOfValue .. value) + local zclh = ZigbeeZcl.ZclHeader({cmd = data_types.ZCLCommandId(0x00)}) + zclh.frame_ctrl:set_cluster_specific() + local addrh = Messages.AddressHeader( + ZigbeeConstants.HUB.ADDR, + ZigbeeConstants.HUB.ENDPOINT, + device:get_short_address(), + device:get_endpoint(TUYA_CLUSTER), + ZigbeeConstants.HA_PROFILE_ID, + TUYA_CLUSTER + ) + local MsgBody = ZigbeeZcl.ZclMessageBody({zcl_header = zclh, zcl_body = PayloadBody}) + local TxMsg = Messages.ZigbeeMessageTx({address_header = addrh, body = MsgBody}) + device:send(TxMsg) + else + -- Standard: use WindowCovering.GoToLiftPercentage + device:send_to_component(command.component, clusters.WindowCovering.server.commands.GoToLiftPercentage(device, device_target_level)) + end +end + +local function shade_level_report_handler(driver, device, value, zb_rx) + -- Since the curtain position status has been updated, + -- the target value is no longer used as a reference for position offset calculation, + -- so LATEST_TARGET_LEVEL needs to be set to nil. + device:set_field(LATEST_TARGET_LEVEL, nil) + -- Get fingerprint configuration + local fingerprint = get_fingerprint(device) + + -- If no fingerprint configuration exists, call the main driver's default handler + if fingerprint == nil then + local windowShade_defaults = require "st.zigbee.defaults.windowShade_defaults" + windowShade_defaults.default_current_lift_percentage_handler(driver, device, value, zb_rx) + end +end + +local stateless_handler = { + NAME = "Zigbee Window Treatment Stateless Step Handlers", + capability_handlers = { + [capabilities.statelessWindowShadeLevelStep.ID] = { + [capabilities.statelessWindowShadeLevelStep.commands.stepShadeLevel.NAME] = step_shade_level_handler, + }, + }, + zigbee_handlers = { + attr = { + -- Window Covering cluster: handles standard curtain/blind devices + -- (e.g., Vimar: Window_Cov_v1.0/Window_Cov_Module_v1.0, SOMFY: Glydea Ultra Curtain/Sonesse series, + -- IKEA: KADRILJ/FYRTUR, Smartwings: WM25/L-Z, Insta GmbH: NEXENTRO Blinds Actuator, + -- Yookee: D10110, Rooms Beautiful: C001, Screen Innovations: WM25/L-Z) + [clusters.WindowCovering.ID] = { + [clusters.WindowCovering.attributes.CurrentPositionLiftPercentage.ID] = shade_level_report_handler, + }, + -- Level cluster: handles devices that use Level cluster for position control + -- (e.g., Feibit: FTB56-ZT218AK1.6/FTB56-ZT218AK1.8, Axis: Gear) + [clusters.Level.ID] = { + [clusters.Level.attributes.CurrentLevel.ID] = shade_level_report_handler, + }, + -- Analog Output cluster: handles Aqara devices that report position via AnalogOutput + -- (e.g., lumi.curtain, lumi.curtain.v1, lumi.curtain.aq2, lumi.curtain.agl001) + [clusters.AnalogOutput.ID] = { + [clusters.AnalogOutput.attributes.PresentValue.ID] = shade_level_report_handler, + }, + }, + }, + can_handle = require("stateless_handler.can_handle") +} + +return stateless_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua b/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua index 959c8d8c22..4487a31030 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua @@ -15,5 +15,6 @@ local sub_drivers = { lazy_load_if_possible("screen-innovations"), lazy_load_if_possible("VIVIDSTORM"), lazy_load_if_possible("HOPOsmart"), + lazy_load_if_possible("stateless_handler"), } return sub_drivers diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_stateless_handler.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_stateless_handler.lua new file mode 100644 index 0000000000..894cc1f81c --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_stateless_handler.lua @@ -0,0 +1,485 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Direct unit test for stateless_handler module +-- Tests boundary conditions for stepShadeLevel command + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local t_utils = require "integration_test.utils" +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local WindowCovering = clusters.WindowCovering +local AnalogOutput = clusters.AnalogOutput + +-- Create mock Aqara curtain device using profile that includes statelessWindowShadeLevelStep capability +-- Profile "window-treatment-no-preset" includes the statelessWindowShadeLevelStep capability +-- Aqara has invert_level = true, meaning: +-- - Device reports: 0 = fully open, 100 = fully closed +-- - UI shows: 0 = fully closed, 100 = fully open (inverted) +-- - UI value = 100 - device value +local aqara_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("window-treatment-no-preset.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "LUMI", + model = "lumi.curtain", + server_clusters = { WindowCovering.ID, AnalogOutput.ID } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(aqara_device) +end + +test.set_test_init_function(test_init) + +-- Test 1: Boundary condition - stepSize = 0 should not send command +-- Tests stateless_handler: stepShadeLevel command with stepSize=0 should not send Zigbee command +test.register_coroutine_test( + "stateless_handler: stepShadeLevel - stepSize 0 does not send command", + function() + -- Set initial state via device report: device=50% -> UI = 100 - 50 = 50% + test.socket.zigbee:__queue_receive({ + aqara_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 50) + }) + -- Device report triggers windowShadeLevel and windowShade events (handled by zigbee_handlers, not stateless_handler) + test.socket.capability:__expect_send({ + aqara_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + aqara_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Send stepShadeLevel command with stepSize = 0 (tests stateless_handler) + test.socket.capability:__queue_receive({ + aqara_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 0 } } + }) + + -- Should NOT send any Zigbee command (stepSize 0 is ignored by stateless_handler) + test.wait_for_events() + end, + { min_api_version = 17 } +) + +-- Test 2: Boundary condition - value clamped to 100 (UI) +-- stateless_handler uses get_latest_state which returns the reported value directly +-- current_level = 10 (device report), step = 50 +-- ui_target_level = clamp(10 + 50, 0, 100) = 60 +-- device_target_level = 60 (not inverted for Aqara in stateless_handler) +test.register_coroutine_test( + "stateless_handler: stepShadeLevel - value clamped to 100", + function() + -- Set initial state: device reports 10% + -- aqara handler emits windowShadeLevel and windowShade events + test.socket.zigbee:__queue_receive({ + aqara_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 10) + }) + -- aqara handler emits shade level and shade events + -- level 10 is partially open (only 0 = closed, 100 = open) + test.socket.capability:__expect_send({ + aqara_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 10 } } + }) + test.socket.capability:__expect_send({ + aqara_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value being used as current level + -- In real scenario, the driver would use device:get_latest_state() to get the current level + aqara_device:set_field("_latestTargetLevel", 10) + + -- Send stepShadeLevel command with stepSize = 50 + -- current_level = 10 (from LATEST_TARGET_LEVEL), step = 50 + -- ui_target_level = clamp(10 + 50, 0, 100) = 60 + -- device_target_level = 60 (not inverted for Aqara in stateless_handler) + test.socket.capability:__queue_receive({ + aqara_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 50 } } + }) + + -- Should send GoToLiftPercentage with value 60 + test.socket.zigbee:__expect_send({ + aqara_device.id, + WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 60) + }) + end, + { min_api_version = 17 } +) + +-- Test 3: Boundary condition - value clamped to 0 (UI, negative step) +-- current_level = 90 (device report), step = -20 +-- ui_target_level = clamp(90 + (-20), 0, 100) = 70 +-- device_target_level = 70 (not inverted for Aqara in stateless_handler) +test.register_coroutine_test( + "stateless_handler: stepShadeLevel - value clamped to 0", + function() + -- Set initial state: device reports 90% + -- aqara handler emits windowShadeLevel and windowShade events + test.socket.zigbee:__queue_receive({ + aqara_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 90) + }) + -- aqara handler emits shade level and shade events + test.socket.capability:__expect_send({ + aqara_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 90 } } + }) + test.socket.capability:__expect_send({ + aqara_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value being used as current level + aqara_device:set_field("_latestTargetLevel", 90) + + -- Send stepShadeLevel command with stepSize = -20 + -- current_level = 90, step = -20 + -- ui_target_level = clamp(90 + (-20), 0, 100) = 70 + -- device_target_level = 70 (not inverted for Aqara in stateless_handler) + test.socket.capability:__queue_receive({ + aqara_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -20 } } + }) + + -- Should send GoToLiftPercentage with value 70 + test.socket.zigbee:__expect_send({ + aqara_device.id, + WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 70) + }) + end, + { min_api_version = 17 } +) + +-- Test 4: Normal step up operation +-- current_level = 70 (device report), step = 10 +-- ui_target_level = clamp(70 + 10, 0, 100) = 80 +-- device_target_level = 80 (not inverted for Aqara in stateless_handler) +test.register_coroutine_test( + "stateless_handler: stepShadeLevel - normal step up", + function() + -- Set initial state: device reports 70% + -- aqara handler emits windowShadeLevel and windowShade events + test.socket.zigbee:__queue_receive({ + aqara_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 70) + }) + -- aqara handler emits shade level and shade events + test.socket.capability:__expect_send({ + aqara_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 70 } } + }) + test.socket.capability:__expect_send({ + aqara_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value being used as current level + aqara_device:set_field("_latestTargetLevel", 70) + + -- Send stepShadeLevel command with stepSize = 10 + -- current_level = 70, step = 10 + -- ui_target_level = clamp(70 + 10, 0, 100) = 80 + -- device_target_level = 80 (not inverted for Aqara in stateless_handler) + test.socket.capability:__queue_receive({ + aqara_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } + }) + + -- Should send GoToLiftPercentage with value 80 + test.socket.zigbee:__expect_send({ + aqara_device.id, + WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 80) + }) + end, + { min_api_version = 17 } +) + +-- Test 5: Normal step down operation +-- current_level = 30 (device report), step = -20 +-- ui_target_level = clamp(30 + (-20), 0, 100) = 10 +-- device_target_level = 10 (not inverted for Aqara in stateless_handler) +test.register_coroutine_test( + "stateless_handler: stepShadeLevel - normal step down", + function() + -- Set initial state: device reports 30% + -- aqara handler emits windowShadeLevel and windowShade events + test.socket.zigbee:__queue_receive({ + aqara_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 30) + }) + -- aqara handler emits shade level and shade events + test.socket.capability:__expect_send({ + aqara_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 30 } } + }) + test.socket.capability:__expect_send({ + aqara_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value being used as current level + aqara_device:set_field("_latestTargetLevel", 30) + + -- Send stepShadeLevel command with stepSize = -20 + -- current_level = 30, step = -20 + -- ui_target_level = clamp(30 + (-20), 0, 100) = 10 + -- device_target_level = 10 (not inverted for Aqara in stateless_handler) + test.socket.capability:__queue_receive({ + aqara_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -20 } } + }) + + -- Should send GoToLiftPercentage with value 10 + test.socket.zigbee:__expect_send({ + aqara_device.id, + WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 10) + }) + end, + { min_api_version = 17 } +) + +-- Test 6: stepSize is nil +test.register_coroutine_test( + "stateless_handler: stepShadeLevel - nil stepSize does not send command", + function() + -- Reset Aqara device state + -- aqara handler emits windowShadeLevel and windowShade events + test.socket.zigbee:__queue_receive({ + aqara_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 50) + }) + -- aqara handler emits shade level and shade events + test.socket.capability:__expect_send({ + aqara_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + aqara_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value being used as current level + aqara_device:set_field("_latestTargetLevel", 50) + + -- Send stepShadeLevel command with nil stepSize + test.socket.capability:__queue_receive({ + aqara_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = {} } + }) + + -- Should NOT send any Zigbee command + test.wait_for_events() + end, + { min_api_version = 17 } +) + +-- Test 7: Boundary condition - minimum stepSize (stepSize = 1) +-- current_level = 50 (device report), step = 1 +-- ui_target_level = clamp(50 + 1, 0, 100) = 51 +-- device_target_level = 51 (not inverted for Aqara in stateless_handler) +test.register_coroutine_test( + "stateless_handler: stepShadeLevel - minimum stepSize 1 works correctly", + function() + -- Set initial state: device reports 50% + -- aqara handler emits windowShadeLevel and windowShade events + test.socket.zigbee:__queue_receive({ + aqara_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 50) + }) + -- aqara handler emits shade level and shade events + test.socket.capability:__expect_send({ + aqara_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + aqara_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value being used as current level + aqara_device:set_field("_latestTargetLevel", 50) + + -- Send stepShadeLevel command with stepSize = 1 (minimum positive step) + -- current_level = 50, step = 1 + -- ui_target_level = clamp(50 + 1, 0, 100) = 51 + -- device_target_level = 51 (not inverted for Aqara in stateless_handler) + test.socket.capability:__queue_receive({ + aqara_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 1 } } + }) + + -- Should send GoToLiftPercentage with value 51 + test.socket.zigbee:__expect_send({ + aqara_device.id, + WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 51) + }) + end, + { min_api_version = 17 } +) + +-- Test 8: Boundary condition - minimum negative stepSize (stepSize = -1) +-- current_level = 50 (device report), step = -1 +-- ui_target_level = clamp(50 + (-1), 0, 100) = 49 +-- device_target_level = 49 (not inverted for Aqara in stateless_handler) +test.register_coroutine_test( + "stateless_handler: stepShadeLevel - minimum negative stepSize -1 works correctly", + function() + -- Set initial state: device reports 50% + -- aqara handler emits windowShadeLevel and windowShade events + test.socket.zigbee:__queue_receive({ + aqara_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 50) + }) + -- aqara handler emits shade level and shade events + test.socket.capability:__expect_send({ + aqara_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + aqara_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value being used as current level + aqara_device:set_field("_latestTargetLevel", 50) + + -- Send stepShadeLevel command with stepSize = -1 (minimum negative step) + -- current_level = 50, step = -1 + -- ui_target_level = clamp(50 + (-1), 0, 100) = 49 + -- device_target_level = 49 (not inverted for Aqara in stateless_handler) + test.socket.capability:__queue_receive({ + aqara_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -1 } } + }) + + -- Should send GoToLiftPercentage with value 49 + test.socket.zigbee:__expect_send({ + aqara_device.id, + WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 49) + }) + end, + { min_api_version = 17 } +) + +-- Test 9: Continuous step operation - multiple consecutive steps with mixed directions +-- Uses LATEST_TARGET_LEVEL field to track state across multiple steps +test.register_coroutine_test( + "stateless_handler: stepShadeLevel - continuous step operations with mixed directions", + function() + -- Set initial state: device reports 50% + -- aqara handler emits windowShadeLevel and windowShade events + test.socket.zigbee:__queue_receive({ + aqara_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 50) + }) + -- aqara handler emits shade level and shade events + test.socket.capability:__expect_send({ + aqara_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + aqara_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value being used as current level + aqara_device:set_field("_latestTargetLevel", 50) + + -- Step 1: stepSize = 10 (up) + -- current_level = 50 (from LATEST_TARGET_LEVEL), step = 10 + -- ui_target_level = clamp(50 + 10, 0, 100) = 60 + -- device_target_level = 60 + test.socket.capability:__queue_receive({ + aqara_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } + }) + test.socket.zigbee:__expect_send({ + aqara_device.id, + WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 60) + }) + test.wait_for_events() + + -- Step 2: stepSize = 10 (up) + -- current_level = 60 (from LATEST_TARGET_LEVEL), step = 10 + -- ui_target_level = clamp(60 + 10, 0, 100) = 70 + -- device_target_level = 70 + test.socket.capability:__queue_receive({ + aqara_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } + }) + test.socket.zigbee:__expect_send({ + aqara_device.id, + WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 70) + }) + test.wait_for_events() + + -- Step 3: stepSize = -20 (down) + -- current_level = 70 (from LATEST_TARGET_LEVEL), step = -20 + -- ui_target_level = clamp(70 + (-20), 0, 100) = 50 + -- device_target_level = 50 + test.socket.capability:__queue_receive({ + aqara_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -20 } } + }) + test.socket.zigbee:__expect_send({ + aqara_device.id, + WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 50) + }) + test.wait_for_events() + + -- Step 4: stepSize = 15 (up) + -- current_level = 50 (from LATEST_TARGET_LEVEL), step = 15 + -- ui_target_level = clamp(50 + 15, 0, 100) = 65 + -- device_target_level = 65 + test.socket.capability:__queue_receive({ + aqara_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 15 } } + }) + test.socket.zigbee:__expect_send({ + aqara_device.id, + WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 65) + }) + test.wait_for_events() + + -- Step 5: stepSize = -5 (down) + -- current_level = 65 (from LATEST_TARGET_LEVEL), step = -5 + -- ui_target_level = clamp(65 + (-5), 0, 100) = 60 + -- device_target_level = 60 + test.socket.capability:__queue_receive({ + aqara_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -5 } } + }) + test.socket.zigbee:__expect_send({ + aqara_device.id, + WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 60) + }) + test.wait_for_events() + end, + { min_api_version = 17 } +) + +test.run_registered_tests() From 1ac256dc1b30c1a0e81bd4c8d0d11d54091f8d16 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Mon, 13 Jul 2026 18:53:23 -0500 Subject: [PATCH 2/2] Handle Zigbee Window Stateless Step Commands --- ...ndow-treatment-aqara-curtain-driver-e1.yml | 2 + .../src/hanssem/init.lua | 13 +- .../zigbee-window-treatment/src/init.lua | 6 +- .../src/stateless_handler/can_handle.lua | 13 - .../src/stateless_handler/fingerprints.lua | 79 --- .../src/stateless_handler/init.lua | 178 ------ .../src/sub_drivers.lua | 1 - .../src/test/test_zigbee_window_treatment.lua | 561 ++++++++++++++++++ ..._zigbee_window_treatment_inverted_step.lua | 346 +++++++++++ ...ee_window_treatment_level_cluster_step.lua | 400 +++++++++++++ ...bee_window_treatment_stateless_handler.lua | 485 --------------- .../src/window_shade_utils.lua | 42 ++ 12 files changed, 1360 insertions(+), 766 deletions(-) delete mode 100644 drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/can_handle.lua delete mode 100644 drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/fingerprints.lua delete mode 100644 drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/init.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_inverted_step.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_level_cluster_step.lua delete mode 100644 drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_stateless_handler.lua diff --git a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara-curtain-driver-e1.yml b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara-curtain-driver-e1.yml index dbd219e523..05e3fe0085 100644 --- a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara-curtain-driver-e1.yml +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara-curtain-driver-e1.yml @@ -6,6 +6,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: stse.initializedStateWithGuide version: 1 - id: stse.hookLockState diff --git a/drivers/SmartThings/zigbee-window-treatment/src/hanssem/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/hanssem/init.lua index f422dd61ca..41b4eb1cac 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/hanssem/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/hanssem/init.lua @@ -25,6 +25,8 @@ local TUYA_CLUSTER = 0xEF00 local DP_TYPE_VALUE = "\x02" local DP_TYPE_ENUM = "\x04" +local SeqNum = 0 + -------- Send Command Function for Tuya Zigbee device ------------- -- ZigbeeMessageTx: -- Uint16: 0x0000 @@ -43,14 +45,6 @@ local DP_TYPE_ENUM = "\x04" -- ReadAttribute: -- AttributeId: 0x0000 --- Get next sequence number for Tuya commands (per-device storage to avoid counter conflicts) -local function get_next_tuya_seq_num(device) - local seq = device:get_field("tuya_seq_num") or 0 - seq = (seq + 1) % 65536 - device:set_field("tuya_seq_num", seq) - return seq -end - local function SendCommand(device, DpId, Type, Value) local addrh = Messages.AddressHeader( ZigbeeConstants.HUB.ADDR, -- Source Address @@ -63,7 +57,8 @@ local function SendCommand(device, DpId, Type, Value) local zclh = ZigbeeZcl.ZclHeader({cmd = data_types.ZCLCommandId(0x00)}) zclh.frame_ctrl:set_cluster_specific() -- sets this frame control field to be cluster specific -- Make a payload body - local strSeqNum = string.pack(">I2", get_next_tuya_seq_num(device)) -- Pack the Sequence number to 2 bytes unsigned integer type with big endian. + SeqNum = (SeqNum + 1) % 65536 + local strSeqNum = string.pack(">I2", SeqNum) -- Pack the Sequence number to 2 bytes unsigned integer type with big endian. local LenOfValue = string.pack(">I2",string.len(Value)) -- Pack length of Value to 2 bytes unsigned integer type wiht big endian. local PayloadBody = generic_body.GenericBody(strSeqNum .. DpId .. Type .. LenOfValue .. Value) local MsgBody = ZigbeeZcl.ZclMessageBody({zcl_header = zclh, zcl_body = PayloadBody}) diff --git a/drivers/SmartThings/zigbee-window-treatment/src/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/init.lua index 0a093645f8..965c2482b4 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/init.lua @@ -33,6 +33,7 @@ local zigbee_window_treatment_driver_template = { capabilities.windowShade, capabilities.windowShadePreset, capabilities.windowShadeLevel, + capabilities.statelessWindowShadeLevelStep, capabilities.powerSource, capabilities.battery }, @@ -40,7 +41,10 @@ local zigbee_window_treatment_driver_template = { [capabilities.windowShadePreset.ID] = { [capabilities.windowShadePreset.commands.setPresetPosition.NAME] = window_shade_utils.set_preset_position_cmd, [capabilities.windowShadePreset.commands.presetPosition.NAME] = window_shade_utils.window_shade_preset_cmd, - } + }, + [capabilities.statelessWindowShadeLevelStep.ID] = { + [capabilities.statelessWindowShadeLevelStep.commands.stepShadeLevel.NAME] = window_shade_utils.step_shade_level_handler() + }, }, lifecycle_handlers = { init = init_handler, diff --git a/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/can_handle.lua deleted file mode 100644 index 209af9f887..0000000000 --- a/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/can_handle.lua +++ /dev/null @@ -1,13 +0,0 @@ --- Copyright 2026 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -local capabilities = require "st.capabilities" - -return function(opts, driver, device) - local can_handle = device:supports_capability(capabilities.statelessWindowShadeLevelStep) - if can_handle then - local subdriver = require("stateless_handler") - return true, subdriver - end - return false -end diff --git a/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/fingerprints.lua deleted file mode 100644 index 583cf82ef9..0000000000 --- a/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/fingerprints.lua +++ /dev/null @@ -1,79 +0,0 @@ --- Copyright 2026 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - --- Brand-specific configuration for window treatment devices --- Supports statelessWindowShadeLevelStep capability - -local FINGERPRINTS = { - -- invert_level = true:The level values on the device side and the driver side need to be inverted. - { - name = "aqara", - mfr = "LUMI", - models = { "lumi.curtain", "lumi.curtain.v1", "lumi.curtain.aq2" }, - invert_level = false, - }, - { - name = "vimar", - mfr = "Vimar", - models = { "Window_Cov_v1.0", "Window_Cov_Module_v1.0" }, - invert_level = true, - }, - { - name = "somfy", - mfr = "SOMFY", - models = { "Glydea Ultra Curtain", "Sonesse 30 WF Roller", "Sonesse 40 Roller" }, - invert_level = true, - }, - { - name = "invert-lift-percentage", - mfrs = { "IKEA of Sweden", "Smartwings", "Insta GmbH" }, - models = {}, - invert_level = true, - }, - { - name = "yoolax", - mfrs = { "Yookee", "yooksmart" }, - models = { "D10110" }, - invert_level = true, - }, - -- use_level_cluster = true: Use Level cluster instead of WindowCovering - -- Level cluster uses 0-254 range, converted from percentage: level_value = math.floor(percentage / 100.0 * 254) - { - name = "feibit", - mfr = "Feibit Co.Ltd", - models = { "FTB56-ZT218AK1.6", "FTB56-ZT218AK1.8" }, - use_level_cluster = true, - }, - { - name = "axis", - mfr = "AXIS", - models = {}, - use_level_cluster = true, - }, - -- Standard devices (no special handling needed) - -- Use WindowCovering.GoToLiftPercentage with 0-100 percentage - { - name = "rooms-beautiful", - mfr = "Rooms Beautiful", - models = { "C001" }, - invert_level = true, - }, - { - name = "hanssem", - mfr = "_TZE204_fzbskaga", - models = { "TS0601" }, - use_tuya_cluster = true, - }, - { - name = "screen-innovations", - mfr = "Screen Innovations", - models = { "WM25/L-Z" }, - }, - { - name = "sombra-shades", - mfr = "Sombra Shades", - models = { "SOMBRA/Z-M" }, - }, -} - -return FINGERPRINTS diff --git a/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/init.lua deleted file mode 100644 index 9e37a37bdc..0000000000 --- a/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/init.lua +++ /dev/null @@ -1,178 +0,0 @@ --- 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 utils = require "st.utils" -local FINGERPRINTS = require "stateless_handler.fingerprints" -local ZigbeeZcl = require "st.zigbee.zcl" -local Messages = require "st.zigbee.messages" -local data_types = require "st.zigbee.data_types" -local ZigbeeConstants = require "st.zigbee.constants" -local generic_body = require "st.zigbee.generic_body" - --- Tuya cluster constants (for hanssem) -local TUYA_CLUSTER = 0xEF00 -local DP_TYPE_VALUE = "\x02" -local DP_ID_SET_POSITION = "\x02" - --- Get next sequence number for Tuya commands (per-device storage to avoid counter conflicts) -local function get_next_tuya_seq_num(device) - local seq = device:get_field("tuya_seq_num") or 0 - seq = (seq + 1) % 65536 - device:set_field("tuya_seq_num", seq) - return seq -end - --- When the curtain is moving, LATEST_TARGET_LEVEL is used to store the latest target position value, --- which will be cleared when the curtain status is updated. -local LATEST_TARGET_LEVEL = "_latestTargetLevel" - --- Get fingerprint for a device -local function get_fingerprint(device) - local manufacturer = device:get_manufacturer() or "" - local model = device:get_model() or "" - - for _, config in ipairs(FINGERPRINTS) do - -- Match manufacturer (case-insensitive) - -- Support both single mfr string and mfrs array - local mfr_match = false - if config.mfrs then - -- Check if manufacturer is in the mfrs array - for _, mfr in ipairs(config.mfrs) do - if string.lower(manufacturer) == string.lower(mfr) then - mfr_match = true - break - end - end - elseif config.mfr then - mfr_match = string.lower(manufacturer) == string.lower(config.mfr) - end - - if mfr_match then - -- If models list is empty, match any model from this manufacturer - if #config.models == 0 then - return config - end - -- Check if model matches any in the list (case-insensitive exact match) - for _, model_pattern in ipairs(config.models) do - if string.lower(model) == string.lower(model_pattern) then - return config - end - end - end - end - return nil -- No matching fingerprint, use default behavior -end - --- Step shade level handler for statelessWindowShadeLevelStep capability -local function step_shade_level_handler(driver, device, command) - -- Get fingerprint-specific configuration - local fingerprint = get_fingerprint(device) - - -- Support both args.stepSize (named) and args[1] (array) formats - local step = command.args.stepSize or command.args[1] - - if not step or step == 0 then - return - end - - -- When LATEST_TARGET_LEVEL is empty, it means the curtain motor is not moving, - -- and capabilities.windowShadeLevel.shadeLevel is used as the reference for position offset calculation; - -- when LATEST_TARGET_LEVEL is not empty, it means the curtain motor is moving, - -- and LATEST_TARGET_LEVEL is used as the reference for position offset calculation to obtain a more accurate position calculation. - local latest_target_level = device:get_field(LATEST_TARGET_LEVEL) - local current_level = latest_target_level or - device:get_latest_state("main", capabilities.windowShadeLevel.ID, capabilities.windowShadeLevel.shadeLevel.NAME) or 0 - - -- Calculate command level (user's expected percentage) - local command_level = utils.clamp_value(current_level + step, 0, 100) - - command_level = utils.round(command_level) - - -- Apply fingerprint-specific inversion if needed - local device_target_level = command_level - if fingerprint and fingerprint.invert_level then - device_target_level = 100 - command_level - end - - -- Set target_level for tracking (store UI value) - device:set_field(LATEST_TARGET_LEVEL, command_level) - - -- Send command based on fingerprint configuration - if fingerprint and fingerprint.use_level_cluster then - -- Feibit/Axis uses Level cluster - local level_value = math.floor(device_target_level / 100.0 * 254) - device:send_to_component(command.component, clusters.Level.server.commands.MoveToLevelWithOnOff(device, level_value)) - elseif fingerprint and fingerprint.use_tuya_cluster then - -- Hanssem uses Tuya custom cluster 0xEF00 - local strSeqNum = string.pack(">I2", get_next_tuya_seq_num(device)) - local value = string.pack(">I4", device_target_level) - local LenOfValue = string.pack(">I2", string.len(value)) - local PayloadBody = generic_body.GenericBody(strSeqNum .. DP_ID_SET_POSITION .. DP_TYPE_VALUE .. LenOfValue .. value) - local zclh = ZigbeeZcl.ZclHeader({cmd = data_types.ZCLCommandId(0x00)}) - zclh.frame_ctrl:set_cluster_specific() - local addrh = Messages.AddressHeader( - ZigbeeConstants.HUB.ADDR, - ZigbeeConstants.HUB.ENDPOINT, - device:get_short_address(), - device:get_endpoint(TUYA_CLUSTER), - ZigbeeConstants.HA_PROFILE_ID, - TUYA_CLUSTER - ) - local MsgBody = ZigbeeZcl.ZclMessageBody({zcl_header = zclh, zcl_body = PayloadBody}) - local TxMsg = Messages.ZigbeeMessageTx({address_header = addrh, body = MsgBody}) - device:send(TxMsg) - else - -- Standard: use WindowCovering.GoToLiftPercentage - device:send_to_component(command.component, clusters.WindowCovering.server.commands.GoToLiftPercentage(device, device_target_level)) - end -end - -local function shade_level_report_handler(driver, device, value, zb_rx) - -- Since the curtain position status has been updated, - -- the target value is no longer used as a reference for position offset calculation, - -- so LATEST_TARGET_LEVEL needs to be set to nil. - device:set_field(LATEST_TARGET_LEVEL, nil) - -- Get fingerprint configuration - local fingerprint = get_fingerprint(device) - - -- If no fingerprint configuration exists, call the main driver's default handler - if fingerprint == nil then - local windowShade_defaults = require "st.zigbee.defaults.windowShade_defaults" - windowShade_defaults.default_current_lift_percentage_handler(driver, device, value, zb_rx) - end -end - -local stateless_handler = { - NAME = "Zigbee Window Treatment Stateless Step Handlers", - capability_handlers = { - [capabilities.statelessWindowShadeLevelStep.ID] = { - [capabilities.statelessWindowShadeLevelStep.commands.stepShadeLevel.NAME] = step_shade_level_handler, - }, - }, - zigbee_handlers = { - attr = { - -- Window Covering cluster: handles standard curtain/blind devices - -- (e.g., Vimar: Window_Cov_v1.0/Window_Cov_Module_v1.0, SOMFY: Glydea Ultra Curtain/Sonesse series, - -- IKEA: KADRILJ/FYRTUR, Smartwings: WM25/L-Z, Insta GmbH: NEXENTRO Blinds Actuator, - -- Yookee: D10110, Rooms Beautiful: C001, Screen Innovations: WM25/L-Z) - [clusters.WindowCovering.ID] = { - [clusters.WindowCovering.attributes.CurrentPositionLiftPercentage.ID] = shade_level_report_handler, - }, - -- Level cluster: handles devices that use Level cluster for position control - -- (e.g., Feibit: FTB56-ZT218AK1.6/FTB56-ZT218AK1.8, Axis: Gear) - [clusters.Level.ID] = { - [clusters.Level.attributes.CurrentLevel.ID] = shade_level_report_handler, - }, - -- Analog Output cluster: handles Aqara devices that report position via AnalogOutput - -- (e.g., lumi.curtain, lumi.curtain.v1, lumi.curtain.aq2, lumi.curtain.agl001) - [clusters.AnalogOutput.ID] = { - [clusters.AnalogOutput.attributes.PresentValue.ID] = shade_level_report_handler, - }, - }, - }, - can_handle = require("stateless_handler.can_handle") -} - -return stateless_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua b/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua index 4487a31030..959c8d8c22 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua @@ -15,6 +15,5 @@ local sub_drivers = { lazy_load_if_possible("screen-innovations"), lazy_load_if_possible("VIVIDSTORM"), lazy_load_if_possible("HOPOsmart"), - lazy_load_if_possible("stateless_handler"), } return sub_drivers diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment.lua index ed01a87249..7e672db47c 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment.lua @@ -337,4 +337,565 @@ test.register_coroutine_test( } ) +test.register_coroutine_test( + "statelessWindowShadeLevelStep stepShadeLevel - positive step", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 50) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + mock_device:set_field("_latestTargetLevel", 50) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 60) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "statelessWindowShadeLevelStep stepShadeLevel - negative step", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 30) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 30 } } + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + mock_device:set_field("_latestTargetLevel", 30) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -20 } } + }) + + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "statelessWindowShadeLevelStep stepShadeLevel - accumulated steps with mixed directions", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__queue_receive({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 50) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + mock_device:set_field("_latestTargetLevel", 50) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 60) + }) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -5 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 55) + }) + test.wait_for_events() + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 15 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 70) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "statelessWindowShadeLevelStep stepShadeLevel - clamped to 100", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__queue_receive({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 90) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 90 } } + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + mock_device:set_field("_latestTargetLevel", 90) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 50 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 100) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "statelessWindowShadeLevelStep stepShadeLevel - clamped to 0", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + test.socket.zigbee:__queue_receive({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 10) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 10 } } + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + mock_device:set_field("_latestTargetLevel", 10) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -50 } } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 0) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "statelessWindowShadeLevelStep stepShadeLevel - zero stepSize", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 50) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + mock_device:set_field("_latestTargetLevel", 50) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 0 } } + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +-- Test utils.set_preset_position_cmd function +test.register_message_test( + "setPresetPosition command updates preset position and emits event", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { + capability = "windowShadePreset", component = "main", + command = "setPresetPosition", args = { 75 } + } + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.windowShadePreset.position(75)) + } + }, + { + min_api_version = 17 + } +) + +-- Test utils.window_shade_preset_cmd with default preset value +test.register_message_test( + "presetPosition command with default value sends GoToLiftPercentage 50", + { + { + channel = "capability", + direction = "receive", + message = { + mock_device.id, + { + capability = "windowShadePreset", component = "main", + command = "presetPosition", args = {} + } + } + }, + { + channel = "zigbee", + direction = "send", + message = { + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 50) + } + } + }, + { + min_api_version = 17 + } +) + +-- Test utils.window_shade_preset_cmd with custom preset position +test.register_coroutine_test( + "presetPosition command with custom preset position sends correct GoToLiftPercentage", + function() + -- First set a custom preset position + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "windowShadePreset", component = "main", + command = "setPresetPosition", args = { 30 } + } + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.windowShadePreset.position(30)) + ) + test.wait_for_events() + + -- Then call presetPosition which should use the custom value + test.socket.capability:__queue_receive({ + mock_device.id, + { + capability = "windowShadePreset", component = "main", + command = "presetPosition", args = {} + } + }) + test.socket.zigbee:__expect_send({ + mock_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(mock_device, 30) + }) + end, + { + min_api_version = 17 + } +) + +-- Test added_handler emits supportedWindowShadeCommands +-- Note: init_handler is already covered by test_init() function +test.register_coroutine_test( + "added_handler emits supportedWindowShadeCommands for open close pause", + function() + local added_mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("window-treatment-profile.yml") } + ) + test.mock_device.add_test_device(added_mock_device) + test.socket.device_lifecycle:__queue_receive({ added_mock_device.id, "added" }) + test.socket.capability:__expect_send( + added_mock_device:generate_test_message("main", capabilities.windowShade.supportedWindowShadeCommands({ "open", "close", "pause" },{ visibility = { displayed = false }})) + ) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +-- Note: The timer callback code (line 61-63 in window_shade_utils.lua) cannot be directly tested +-- because the test framework's timer mechanism doesn't execute callbacks created by call_with_delay. +-- However, the timer cancellation and re-creation logic is exercised by the +-- "accumulated steps with mixed directions" test, which verifies that multiple +-- consecutive step commands properly accumulate. + +-- Test init_handler with existing preset position from field +test.register_coroutine_test( + "init_handler uses preset position from field for existing devices", + function() + local init_mock_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("window-treatment-profile.yml") } + ) + init_mock_device:set_field("_presetLevel", 60, {persist = true}) + test.mock_device.add_test_device(init_mock_device) + + -- Trigger init lifecycle event + test.socket.device_lifecycle:__queue_receive({ init_mock_device.id, "init" }) + + -- init_handler should emit the preset position event from field + test.socket.capability:__expect_send( + init_mock_device:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, { visibility = { displayed = false }})) + ) + test.socket.capability:__expect_send( + init_mock_device:generate_test_message("main", capabilities.windowShadePreset.position(60, { visibility = {displayed = false}})) + ) + end, + { + min_api_version = 17 + } +) + +-- Test init_handler with preset position from preferences +test.register_coroutine_test( + "init_handler uses preset position from preferences when field is nil", + function() + local init_mock_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("window-treatment-profile.yml"), + preferences = { presetPosition = 80 } + } + ) + test.mock_device.add_test_device(init_mock_device) + + -- Trigger init lifecycle event + test.socket.device_lifecycle:__queue_receive({ init_mock_device.id, "init" }) + + -- init_handler should emit the preset position event from preferences + test.socket.capability:__expect_send( + init_mock_device:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, { visibility = { displayed = false }})) + ) + test.socket.capability:__expect_send( + init_mock_device:generate_test_message("main", capabilities.windowShadePreset.position(80, { visibility = {displayed = false}})) + ) + end, + { + min_api_version = 17 + } +) + +-- Test step_shade_level_handler with zero step +test.register_coroutine_test( + "step_shade_level_handler returns early when stepSize is zero", + function() + test.socket.zigbee:__queue_receive({ + mock_device.id, + clusters.WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(mock_device, 50) + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + mock_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + mock_device:set_field("_latestTargetLevel", 50) + + -- Send zero step - should return early without sending any command + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 0 } } + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +-- Test window_shade_utils.get_preset_level function indirectly through presetPosition command +-- The get_preset_level function is called by window_shade_preset_cmd which is tested here + +test.register_coroutine_test( + "get_preset_level coverage - state path: presetPosition uses value from state", + function() + -- This test verifies get_preset_level returns value from state + -- by setting state and calling presetPosition command + local state_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("window-treatment-profile.yml") } + ) + test.mock_device.add_test_device(state_device) + + -- First set preset position to establish state + test.socket.capability:__queue_receive({ + state_device.id, + { + capability = "windowShadePreset", component = "main", + command = "setPresetPosition", args = { 45 } + } + }) + test.socket.capability:__expect_send( + state_device:generate_test_message("main", capabilities.windowShadePreset.position(45)) + ) + test.wait_for_events() + + -- Then call presetPosition - should use state value (45) + test.socket.capability:__queue_receive({ + state_device.id, + { + capability = "windowShadePreset", component = "main", + command = "presetPosition", args = {} + } + }) + test.socket.zigbee:__expect_send({ + state_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(state_device, 45) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "get_preset_level coverage - field path: presetPosition uses value from field when no state", + function() + -- This test verifies get_preset_level returns value from field + local field_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("window-treatment-profile.yml") } + ) + local window_shade_utils = require "window_shade_utils" + test.mock_device.add_test_device(field_device) + + -- Set field directly without emitting event (no state) + field_device:set_field(window_shade_utils.PRESET_LEVEL_KEY, 55, {persist = true}) + + -- Call presetPosition - should use field value (55) + test.socket.capability:__queue_receive({ + field_device.id, + { + capability = "windowShadePreset", component = "main", + command = "presetPosition", args = {} + } + }) + test.socket.zigbee:__expect_send({ + field_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(field_device, 55) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "get_preset_level coverage - preferences path: presetPosition uses value from preferences", + function() + -- This test verifies get_preset_level returns value from preferences + local prefs_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("window-treatment-profile.yml"), + preferences = { presetPosition = 65 } + } + ) + test.mock_device.add_test_device(prefs_device) + + -- Call presetPosition - should use preferences value (65) + test.socket.capability:__queue_receive({ + prefs_device.id, + { + capability = "windowShadePreset", component = "main", + command = "presetPosition", args = {} + } + }) + test.socket.zigbee:__expect_send({ + prefs_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(prefs_device, 65) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "get_preset_level coverage - default path: presetPosition uses default value 50", + function() + -- This test verifies get_preset_level returns default value (50) + -- when state, field, and preferences are all nil + local default_device = test.mock_device.build_test_zigbee_device( + { profile = t_utils.get_profile_definition("window-treatment-profile.yml") } + ) + test.mock_device.add_test_device(default_device) + -- Call presetPosition - should use default value (50) + test.socket.capability:__queue_receive({ + default_device.id, + { + capability = "windowShadePreset", component = "main", + command = "presetPosition", args = {} + } + }) + test.socket.zigbee:__expect_send({ + default_device.id, + clusters.WindowCovering.server.commands.GoToLiftPercentage(default_device, 50) + }) + test.wait_for_events() + end, + { + min_api_version = 17 + } +) + test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_inverted_step.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_inverted_step.lua new file mode 100644 index 0000000000..3c7c0cbee2 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_inverted_step.lua @@ -0,0 +1,346 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Test for statelessWindowShadeLevelStep capability with Somfy (reverse) devices +-- Tests devices like Somfy Glydea that use inverted lift percentage + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local t_utils = require "integration_test.utils" +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local WindowCovering = clusters.WindowCovering + +-- Create mock Somfy Glydea device using WindowCovering cluster with inverted logic +-- This device matches the somfy sub-driver which inverts the lift percentage +local somfy_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("window-treatment-profile.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "SOMFY", + model = "Glydea Ultra Curtain", + server_clusters = { WindowCovering.ID } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(somfy_device) + test.socket.capability:__expect_send( + somfy_device:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed=false}})) + ) + test.socket.capability:__expect_send( + somfy_device:generate_test_message("main", capabilities.windowShadePreset.position(50, {visibility = {displayed=false}})) + ) +end + +test.set_test_init_function(test_init) + +-- Test 1: Boundary condition - stepSize = 0 should not send command +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Somfy (reverse) - stepSize 0 does not send command", + function() + -- Set initial state via WindowCovering cluster report: 50% (inverted: device reports 50 for 50% shade) + test.socket.zigbee:__queue_receive({ + somfy_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(somfy_device, 50) + }) + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + -- Somfy sub-driver emits "opening" for initial report when no previous state exists + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + somfy_device:set_field("_latestTargetLevel", 50) + + -- Send stepShadeLevel command with stepSize = 0 + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 0 } } + }) + + -- Should NOT send any Zigbee command (stepSize 0 is ignored) + test.wait_for_events() + end, + { min_api_version = 17 } +) + +-- Test 2: Normal step up operation with Somfy (reverse) device +-- Somfy inverts the value: shadeLevel 60 means device goes to 100-60=40 +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Somfy (reverse) - normal step up", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: WindowCovering cluster reports 50 (inverted: 100-50=50 shade level) + test.socket.zigbee:__queue_receive({ + somfy_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(somfy_device, 50) + }) + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + -- Somfy sub-driver emits "opening" for initial report when no previous state exists + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + somfy_device:set_field("_latestTargetLevel", 50) + + -- Send stepShadeLevel command with stepSize = 10 + -- new_target_level = clamp(50 + 10, 0, 100) = 60 + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } + }) + + -- Note: Somfy sub-driver's window_shade_level_cmd directly sends Zigbee command without emitting events + -- Somfy sub-driver's window_shade_level_cmd inverts the value: 100 - 60 = 40 + test.socket.zigbee:__expect_send({ + somfy_device.id, + WindowCovering.server.commands.GoToLiftPercentage(somfy_device, 40) + }) + end, + { min_api_version = 17 } +) + +-- Test 3: Normal step down operation with Somfy (reverse) device +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Somfy (reverse) - normal step down", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: WindowCovering cluster reports 25 (inverted: 100-25=75 shade level) + test.socket.zigbee:__queue_receive({ + somfy_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(somfy_device, 25) + }) + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 75 } } + }) + -- Somfy sub-driver emits "opening" for initial report when no previous state exists + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + somfy_device:set_field("_latestTargetLevel", 75) + + -- Send stepShadeLevel command with stepSize = -25 + -- new_target_level = clamp(75 + (-25), 0, 100) = 50 + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -25 } } + }) + + -- Note: Somfy sub-driver's window_shade_level_cmd directly sends Zigbee command without emitting events + -- Somfy sub-driver's window_shade_level_cmd inverts the value: 100 - 50 = 50 + test.socket.zigbee:__expect_send({ + somfy_device.id, + WindowCovering.server.commands.GoToLiftPercentage(somfy_device, 50) + }) + end, + { min_api_version = 17 } +) + +-- Test 4: Boundary condition - value clamped to 100 (maximum) +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Somfy (reverse) - value clamped to 100", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: WindowCovering cluster reports 10 (inverted: 100-10=90 shade level) + test.socket.zigbee:__queue_receive({ + somfy_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(somfy_device, 10) + }) + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 90 } } + }) + -- Somfy sub-driver emits "opening" for initial report when no previous state exists + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + somfy_device:set_field("_latestTargetLevel", 90) + + -- Send stepShadeLevel command with stepSize = 50 + -- new_target_level = clamp(90 + 50, 0, 100) = 100 + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 50 } } + }) + + -- Note: Somfy sub-driver's window_shade_level_cmd directly sends Zigbee command without emitting events + -- Somfy sub-driver's window_shade_level_cmd inverts the value: 100 - 100 = 0 + test.socket.zigbee:__expect_send({ + somfy_device.id, + WindowCovering.server.commands.GoToLiftPercentage(somfy_device, 0) + }) + end, + { min_api_version = 17 } +) + +-- Test 5: Boundary condition - value clamped to 0 (minimum) +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Somfy (reverse) - value clamped to 0", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: WindowCovering cluster reports 80 (inverted: 100-80=20 shade level) + test.socket.zigbee:__queue_receive({ + somfy_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(somfy_device, 80) + }) + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 20 } } + }) + -- Somfy sub-driver emits "opening" for initial report when no previous state exists + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + somfy_device:set_field("_latestTargetLevel", 20) + + -- Send stepShadeLevel command with stepSize = -50 + -- new_target_level = clamp(20 + (-50), 0, 100) = 0 + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -50 } } + }) + + -- Note: Somfy sub-driver's window_shade_level_cmd directly sends Zigbee command without emitting events + -- Somfy sub-driver's window_shade_level_cmd inverts the value: 100 - 0 = 100 + test.socket.zigbee:__expect_send({ + somfy_device.id, + WindowCovering.server.commands.GoToLiftPercentage(somfy_device, 100) + }) + end, + { min_api_version = 17 } +) + +-- Test 6: Continuous step operations with mixed directions +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Somfy (reverse) - continuous steps with mixed directions", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: WindowCovering cluster reports 50 (inverted: 100-50=50 shade level) + test.socket.zigbee:__queue_receive({ + somfy_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(somfy_device, 50) + }) + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + -- Somfy sub-driver emits "opening" for initial report when no previous state exists + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + somfy_device:set_field("_latestTargetLevel", 50) + + -- Step 1: stepSize = 10 (up) -> 60% + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } + }) + -- Note: Somfy sub-driver's window_shade_level_cmd directly sends Zigbee command without emitting events + -- Somfy sub-driver's window_shade_level_cmd inverts the value: 100 - 60 = 40 + test.socket.zigbee:__expect_send({ + somfy_device.id, + WindowCovering.server.commands.GoToLiftPercentage(somfy_device, 40) + }) + test.wait_for_events() + + -- Step 2: stepSize = -5 (down) -> 55% + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -5 } } + }) + -- Note: Somfy sub-driver's window_shade_level_cmd directly sends Zigbee command without emitting events + -- Somfy sub-driver's window_shade_level_cmd inverts the value: 100 - 55 = 45 + test.socket.zigbee:__expect_send({ + somfy_device.id, + WindowCovering.server.commands.GoToLiftPercentage(somfy_device, 45) + }) + test.wait_for_events() + + -- Step 3: stepSize = 20 (up) -> 75% + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 20 } } + }) + -- Note: Somfy sub-driver's window_shade_level_cmd directly sends Zigbee command without emitting events + -- Somfy sub-driver's window_shade_level_cmd inverts the value: 100 - 75 = 25 + test.socket.zigbee:__expect_send({ + somfy_device.id, + WindowCovering.server.commands.GoToLiftPercentage(somfy_device, 25) + }) + test.wait_for_events() + end, + { min_api_version = 17 } +) + +-- Test 7: nil stepSize should not send command +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Somfy (reverse) - nil stepSize does not send command", + function() + -- Set initial state: WindowCovering cluster reports 50 (inverted: 100-50=50 shade level) + test.socket.zigbee:__queue_receive({ + somfy_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(somfy_device, 50) + }) + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + -- Somfy sub-driver emits "opening" for initial report when no previous state exists + test.socket.capability:__expect_send({ + somfy_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + somfy_device:set_field("_latestTargetLevel", 50) + + -- Send stepShadeLevel command with nil stepSize + test.socket.capability:__queue_receive({ + somfy_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = {} } + }) + + -- Should NOT send any Zigbee command + test.wait_for_events() + end, + { min_api_version = 17 } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_level_cluster_step.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_level_cluster_step.lua new file mode 100644 index 0000000000..602aa34bd4 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_level_cluster_step.lua @@ -0,0 +1,400 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Test for statelessWindowShadeLevelStep capability with Level cluster devices +-- Tests devices like AXIS Gear that use Level cluster for window covering control + +local capabilities = require "st.capabilities" +local clusters = require "st.zigbee.zcl.clusters" +local t_utils = require "integration_test.utils" +local test = require "integration_test" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" + +local Level = clusters.Level +local WindowCovering = clusters.WindowCovering + +-- Create mock AXIS Gear device using Level cluster +-- This device matches the axis sub-driver which uses Level cluster +local axis_device = test.mock_device.build_test_zigbee_device( + { + profile = t_utils.get_profile_definition("window-treatment-profile.yml"), + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "AXIS", + model = "Gear", + server_clusters = { Level.ID, WindowCovering.ID } + } + } + } +) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.mock_device.add_test_device(axis_device) + test.socket.capability:__expect_send( + axis_device:generate_test_message("main", capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed=false}})) + ) + test.socket.capability:__expect_send( + axis_device:generate_test_message("main", capabilities.windowShadePreset.position(50, {visibility = {displayed=false}})) + ) +end + +test.set_test_init_function(test_init) + +-- Test 1: Boundary condition - stepSize = 0 should not send command +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Level cluster - stepSize 0 does not send command", + function() + -- Set initial state via Level cluster report: 50% (127 out of 254) + test.socket.zigbee:__queue_receive({ + axis_device.id, + Level.attributes.CurrentLevel:build_test_attr_report(axis_device, 127) + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + axis_device:set_field("_latestTargetLevel", 50) + + -- Send stepShadeLevel command with stepSize = 0 + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 0 } } + }) + + -- Should NOT send any Zigbee command (stepSize 0 is ignored) + test.wait_for_events() + end, + { min_api_version = 17 } +) + +-- Test 2: Normal step up operation with Level cluster +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Level cluster - normal step up", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: Level cluster reports 50% (127 out of 254) + test.socket.zigbee:__queue_receive({ + axis_device.id, + Level.attributes.CurrentLevel:build_test_attr_report(axis_device, 127) + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + axis_device:set_field("_latestTargetLevel", 50) + + -- Send stepShadeLevel command with stepSize = 10 + -- new_target_level = clamp(50 + 10, 0, 100) = 60 + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } + }) + + -- AXIS sub-driver emits shadeLevel event when handling setShadeLevel (injected by main driver) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 60 } } + }) + -- AXIS sub-driver also emits windowShade event (opening because 60 > 50) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + + -- Should send MoveToLevelWithOnOff with level 60 (152 out of 254, rounded from 152.4) + test.socket.zigbee:__expect_send({ + axis_device.id, + Level.server.commands.MoveToLevelWithOnOff(axis_device, 152) + }) + end, + { min_api_version = 17 } +) + +-- Test 3: Normal step down operation with Level cluster +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Level cluster - normal step down", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: Level cluster reports 75% (191 out of 254) + test.socket.zigbee:__queue_receive({ + axis_device.id, + Level.attributes.CurrentLevel:build_test_attr_report(axis_device, 191) + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 75 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + axis_device:set_field("_latestTargetLevel", 75) + + -- Send stepShadeLevel command with stepSize = -25 + -- new_target_level = clamp(75 + (-25), 0, 100) = 50 + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -25 } } + }) + + -- AXIS sub-driver emits shadeLevel event when handling setShadeLevel (injected by main driver) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + -- AXIS sub-driver also emits windowShade event (closing because 50 < 75) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "closing" } } + }) + + -- Should send MoveToLevelWithOnOff with level 50 (127 out of 254) + test.socket.zigbee:__expect_send({ + axis_device.id, + Level.server.commands.MoveToLevelWithOnOff(axis_device, 127) + }) + end, + { min_api_version = 17 } +) + +-- Test 4: Boundary condition - value clamped to 100 (maximum) +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Level cluster - value clamped to 100", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: Level cluster reports 90% (229 out of 254) + test.socket.zigbee:__queue_receive({ + axis_device.id, + Level.attributes.CurrentLevel:build_test_attr_report(axis_device, 229) + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 90 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + axis_device:set_field("_latestTargetLevel", 90) + + -- Send stepShadeLevel command with stepSize = 50 + -- new_target_level = clamp(90 + 50, 0, 100) = 100 + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 50 } } + }) + + -- AXIS sub-driver emits shadeLevel event when handling setShadeLevel (injected by main driver) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 100 } } + }) + -- AXIS sub-driver also emits windowShade event (opening because 100 > 90) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + + -- Should send MoveToLevelWithOnOff with level 100 (254 out of 254) + test.socket.zigbee:__expect_send({ + axis_device.id, + Level.server.commands.MoveToLevelWithOnOff(axis_device, 254) + }) + end, + { min_api_version = 17 } +) + +-- Test 5: Boundary condition - value clamped to 0 (minimum) +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Level cluster - value clamped to 0", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: Level cluster reports 20% (51 out of 254) + test.socket.zigbee:__queue_receive({ + axis_device.id, + Level.attributes.CurrentLevel:build_test_attr_report(axis_device, 51) + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 20 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + axis_device:set_field("_latestTargetLevel", 20) + + -- Send stepShadeLevel command with stepSize = -50 + -- new_target_level = clamp(20 + (-50), 0, 100) = 0 + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -50 } } + }) + + -- AXIS sub-driver emits shadeLevel event when handling setShadeLevel (injected by main driver) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 0 } } + }) + -- AXIS sub-driver also emits windowShade event (closing because 0 < 20) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "closing" } } + }) + + -- Should send MoveToLevelWithOnOff with level 0 (0 out of 254) + test.socket.zigbee:__expect_send({ + axis_device.id, + Level.server.commands.MoveToLevelWithOnOff(axis_device, 0) + }) + end, + { min_api_version = 17 } +) + +-- Test 6: Continuous step operations with mixed directions +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Level cluster - continuous steps with mixed directions", + function() + test.socket.zigbee:__set_channel_ordering("relaxed") + -- Set initial state: Level cluster reports 50% (127 out of 254) + test.socket.zigbee:__queue_receive({ + axis_device.id, + Level.attributes.CurrentLevel:build_test_attr_report(axis_device, 127) + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + axis_device:set_field("_latestTargetLevel", 50) + + -- Step 1: stepSize = 10 (up) -> 60% + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } + }) + -- AXIS sub-driver emits shadeLevel and windowShade events + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 60 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.socket.zigbee:__expect_send({ + axis_device.id, + Level.server.commands.MoveToLevelWithOnOff(axis_device, 152) + }) + test.wait_for_events() + + -- Step 2: stepSize = -5 (down) -> 55% + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -5 } } + }) + -- AXIS sub-driver emits shadeLevel and windowShade events + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 55 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "closing" } } + }) + test.socket.zigbee:__expect_send({ + axis_device.id, + Level.server.commands.MoveToLevelWithOnOff(axis_device, 140) + }) + test.wait_for_events() + + -- Step 3: stepSize = 20 (up) -> 75% + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 20 } } + }) + -- AXIS sub-driver emits shadeLevel and windowShade events + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 75 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "opening" } } + }) + test.socket.zigbee:__expect_send({ + axis_device.id, + Level.server.commands.MoveToLevelWithOnOff(axis_device, 191) + }) + test.wait_for_events() + end, + { min_api_version = 17 } +) + +-- Test 7: nil stepSize should not send command +test.register_coroutine_test( + "statelessWindowShadeLevelStep with Level cluster - nil stepSize does not send command", + function() + -- Set initial state: Level cluster reports 50% (127 out of 254) + test.socket.zigbee:__queue_receive({ + axis_device.id, + Level.attributes.CurrentLevel:build_test_attr_report(axis_device, 127) + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } + }) + test.socket.capability:__expect_send({ + axis_device.id, + { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } + }) + test.wait_for_events() + + -- Manually set LATEST_TARGET_LEVEL to simulate the reported value + axis_device:set_field("_latestTargetLevel", 50) + + -- Send stepShadeLevel command with nil stepSize + test.socket.capability:__queue_receive({ + axis_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = {} } + }) + + -- Should NOT send any Zigbee command + test.wait_for_events() + end, + { min_api_version = 17 } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_stateless_handler.lua b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_stateless_handler.lua deleted file mode 100644 index 894cc1f81c..0000000000 --- a/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_stateless_handler.lua +++ /dev/null @@ -1,485 +0,0 @@ --- Copyright 2026 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - --- Direct unit test for stateless_handler module --- Tests boundary conditions for stepShadeLevel command - -local capabilities = require "st.capabilities" -local clusters = require "st.zigbee.zcl.clusters" -local t_utils = require "integration_test.utils" -local test = require "integration_test" -local zigbee_test_utils = require "integration_test.zigbee_test_utils" - -local WindowCovering = clusters.WindowCovering -local AnalogOutput = clusters.AnalogOutput - --- Create mock Aqara curtain device using profile that includes statelessWindowShadeLevelStep capability --- Profile "window-treatment-no-preset" includes the statelessWindowShadeLevelStep capability --- Aqara has invert_level = true, meaning: --- - Device reports: 0 = fully open, 100 = fully closed --- - UI shows: 0 = fully closed, 100 = fully open (inverted) --- - UI value = 100 - device value -local aqara_device = test.mock_device.build_test_zigbee_device( - { - profile = t_utils.get_profile_definition("window-treatment-no-preset.yml"), - fingerprinted_endpoint_id = 0x01, - zigbee_endpoints = { - [1] = { - id = 1, - manufacturer = "LUMI", - model = "lumi.curtain", - server_clusters = { WindowCovering.ID, AnalogOutput.ID } - } - } - } -) - -zigbee_test_utils.prepare_zigbee_env_info() - -local function test_init() - test.mock_device.add_test_device(aqara_device) -end - -test.set_test_init_function(test_init) - --- Test 1: Boundary condition - stepSize = 0 should not send command --- Tests stateless_handler: stepShadeLevel command with stepSize=0 should not send Zigbee command -test.register_coroutine_test( - "stateless_handler: stepShadeLevel - stepSize 0 does not send command", - function() - -- Set initial state via device report: device=50% -> UI = 100 - 50 = 50% - test.socket.zigbee:__queue_receive({ - aqara_device.id, - WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 50) - }) - -- Device report triggers windowShadeLevel and windowShade events (handled by zigbee_handlers, not stateless_handler) - test.socket.capability:__expect_send({ - aqara_device.id, - { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } - }) - test.socket.capability:__expect_send({ - aqara_device.id, - { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } - }) - test.wait_for_events() - - -- Send stepShadeLevel command with stepSize = 0 (tests stateless_handler) - test.socket.capability:__queue_receive({ - aqara_device.id, - { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 0 } } - }) - - -- Should NOT send any Zigbee command (stepSize 0 is ignored by stateless_handler) - test.wait_for_events() - end, - { min_api_version = 17 } -) - --- Test 2: Boundary condition - value clamped to 100 (UI) --- stateless_handler uses get_latest_state which returns the reported value directly --- current_level = 10 (device report), step = 50 --- ui_target_level = clamp(10 + 50, 0, 100) = 60 --- device_target_level = 60 (not inverted for Aqara in stateless_handler) -test.register_coroutine_test( - "stateless_handler: stepShadeLevel - value clamped to 100", - function() - -- Set initial state: device reports 10% - -- aqara handler emits windowShadeLevel and windowShade events - test.socket.zigbee:__queue_receive({ - aqara_device.id, - WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 10) - }) - -- aqara handler emits shade level and shade events - -- level 10 is partially open (only 0 = closed, 100 = open) - test.socket.capability:__expect_send({ - aqara_device.id, - { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 10 } } - }) - test.socket.capability:__expect_send({ - aqara_device.id, - { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } - }) - test.wait_for_events() - - -- Manually set LATEST_TARGET_LEVEL to simulate the reported value being used as current level - -- In real scenario, the driver would use device:get_latest_state() to get the current level - aqara_device:set_field("_latestTargetLevel", 10) - - -- Send stepShadeLevel command with stepSize = 50 - -- current_level = 10 (from LATEST_TARGET_LEVEL), step = 50 - -- ui_target_level = clamp(10 + 50, 0, 100) = 60 - -- device_target_level = 60 (not inverted for Aqara in stateless_handler) - test.socket.capability:__queue_receive({ - aqara_device.id, - { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 50 } } - }) - - -- Should send GoToLiftPercentage with value 60 - test.socket.zigbee:__expect_send({ - aqara_device.id, - WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 60) - }) - end, - { min_api_version = 17 } -) - --- Test 3: Boundary condition - value clamped to 0 (UI, negative step) --- current_level = 90 (device report), step = -20 --- ui_target_level = clamp(90 + (-20), 0, 100) = 70 --- device_target_level = 70 (not inverted for Aqara in stateless_handler) -test.register_coroutine_test( - "stateless_handler: stepShadeLevel - value clamped to 0", - function() - -- Set initial state: device reports 90% - -- aqara handler emits windowShadeLevel and windowShade events - test.socket.zigbee:__queue_receive({ - aqara_device.id, - WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 90) - }) - -- aqara handler emits shade level and shade events - test.socket.capability:__expect_send({ - aqara_device.id, - { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 90 } } - }) - test.socket.capability:__expect_send({ - aqara_device.id, - { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } - }) - test.wait_for_events() - - -- Manually set LATEST_TARGET_LEVEL to simulate the reported value being used as current level - aqara_device:set_field("_latestTargetLevel", 90) - - -- Send stepShadeLevel command with stepSize = -20 - -- current_level = 90, step = -20 - -- ui_target_level = clamp(90 + (-20), 0, 100) = 70 - -- device_target_level = 70 (not inverted for Aqara in stateless_handler) - test.socket.capability:__queue_receive({ - aqara_device.id, - { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -20 } } - }) - - -- Should send GoToLiftPercentage with value 70 - test.socket.zigbee:__expect_send({ - aqara_device.id, - WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 70) - }) - end, - { min_api_version = 17 } -) - --- Test 4: Normal step up operation --- current_level = 70 (device report), step = 10 --- ui_target_level = clamp(70 + 10, 0, 100) = 80 --- device_target_level = 80 (not inverted for Aqara in stateless_handler) -test.register_coroutine_test( - "stateless_handler: stepShadeLevel - normal step up", - function() - -- Set initial state: device reports 70% - -- aqara handler emits windowShadeLevel and windowShade events - test.socket.zigbee:__queue_receive({ - aqara_device.id, - WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 70) - }) - -- aqara handler emits shade level and shade events - test.socket.capability:__expect_send({ - aqara_device.id, - { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 70 } } - }) - test.socket.capability:__expect_send({ - aqara_device.id, - { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } - }) - test.wait_for_events() - - -- Manually set LATEST_TARGET_LEVEL to simulate the reported value being used as current level - aqara_device:set_field("_latestTargetLevel", 70) - - -- Send stepShadeLevel command with stepSize = 10 - -- current_level = 70, step = 10 - -- ui_target_level = clamp(70 + 10, 0, 100) = 80 - -- device_target_level = 80 (not inverted for Aqara in stateless_handler) - test.socket.capability:__queue_receive({ - aqara_device.id, - { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } - }) - - -- Should send GoToLiftPercentage with value 80 - test.socket.zigbee:__expect_send({ - aqara_device.id, - WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 80) - }) - end, - { min_api_version = 17 } -) - --- Test 5: Normal step down operation --- current_level = 30 (device report), step = -20 --- ui_target_level = clamp(30 + (-20), 0, 100) = 10 --- device_target_level = 10 (not inverted for Aqara in stateless_handler) -test.register_coroutine_test( - "stateless_handler: stepShadeLevel - normal step down", - function() - -- Set initial state: device reports 30% - -- aqara handler emits windowShadeLevel and windowShade events - test.socket.zigbee:__queue_receive({ - aqara_device.id, - WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 30) - }) - -- aqara handler emits shade level and shade events - test.socket.capability:__expect_send({ - aqara_device.id, - { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 30 } } - }) - test.socket.capability:__expect_send({ - aqara_device.id, - { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } - }) - test.wait_for_events() - - -- Manually set LATEST_TARGET_LEVEL to simulate the reported value being used as current level - aqara_device:set_field("_latestTargetLevel", 30) - - -- Send stepShadeLevel command with stepSize = -20 - -- current_level = 30, step = -20 - -- ui_target_level = clamp(30 + (-20), 0, 100) = 10 - -- device_target_level = 10 (not inverted for Aqara in stateless_handler) - test.socket.capability:__queue_receive({ - aqara_device.id, - { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -20 } } - }) - - -- Should send GoToLiftPercentage with value 10 - test.socket.zigbee:__expect_send({ - aqara_device.id, - WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 10) - }) - end, - { min_api_version = 17 } -) - --- Test 6: stepSize is nil -test.register_coroutine_test( - "stateless_handler: stepShadeLevel - nil stepSize does not send command", - function() - -- Reset Aqara device state - -- aqara handler emits windowShadeLevel and windowShade events - test.socket.zigbee:__queue_receive({ - aqara_device.id, - WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 50) - }) - -- aqara handler emits shade level and shade events - test.socket.capability:__expect_send({ - aqara_device.id, - { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } - }) - test.socket.capability:__expect_send({ - aqara_device.id, - { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } - }) - test.wait_for_events() - - -- Manually set LATEST_TARGET_LEVEL to simulate the reported value being used as current level - aqara_device:set_field("_latestTargetLevel", 50) - - -- Send stepShadeLevel command with nil stepSize - test.socket.capability:__queue_receive({ - aqara_device.id, - { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = {} } - }) - - -- Should NOT send any Zigbee command - test.wait_for_events() - end, - { min_api_version = 17 } -) - --- Test 7: Boundary condition - minimum stepSize (stepSize = 1) --- current_level = 50 (device report), step = 1 --- ui_target_level = clamp(50 + 1, 0, 100) = 51 --- device_target_level = 51 (not inverted for Aqara in stateless_handler) -test.register_coroutine_test( - "stateless_handler: stepShadeLevel - minimum stepSize 1 works correctly", - function() - -- Set initial state: device reports 50% - -- aqara handler emits windowShadeLevel and windowShade events - test.socket.zigbee:__queue_receive({ - aqara_device.id, - WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 50) - }) - -- aqara handler emits shade level and shade events - test.socket.capability:__expect_send({ - aqara_device.id, - { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } - }) - test.socket.capability:__expect_send({ - aqara_device.id, - { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } - }) - test.wait_for_events() - - -- Manually set LATEST_TARGET_LEVEL to simulate the reported value being used as current level - aqara_device:set_field("_latestTargetLevel", 50) - - -- Send stepShadeLevel command with stepSize = 1 (minimum positive step) - -- current_level = 50, step = 1 - -- ui_target_level = clamp(50 + 1, 0, 100) = 51 - -- device_target_level = 51 (not inverted for Aqara in stateless_handler) - test.socket.capability:__queue_receive({ - aqara_device.id, - { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 1 } } - }) - - -- Should send GoToLiftPercentage with value 51 - test.socket.zigbee:__expect_send({ - aqara_device.id, - WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 51) - }) - end, - { min_api_version = 17 } -) - --- Test 8: Boundary condition - minimum negative stepSize (stepSize = -1) --- current_level = 50 (device report), step = -1 --- ui_target_level = clamp(50 + (-1), 0, 100) = 49 --- device_target_level = 49 (not inverted for Aqara in stateless_handler) -test.register_coroutine_test( - "stateless_handler: stepShadeLevel - minimum negative stepSize -1 works correctly", - function() - -- Set initial state: device reports 50% - -- aqara handler emits windowShadeLevel and windowShade events - test.socket.zigbee:__queue_receive({ - aqara_device.id, - WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 50) - }) - -- aqara handler emits shade level and shade events - test.socket.capability:__expect_send({ - aqara_device.id, - { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } - }) - test.socket.capability:__expect_send({ - aqara_device.id, - { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } - }) - test.wait_for_events() - - -- Manually set LATEST_TARGET_LEVEL to simulate the reported value being used as current level - aqara_device:set_field("_latestTargetLevel", 50) - - -- Send stepShadeLevel command with stepSize = -1 (minimum negative step) - -- current_level = 50, step = -1 - -- ui_target_level = clamp(50 + (-1), 0, 100) = 49 - -- device_target_level = 49 (not inverted for Aqara in stateless_handler) - test.socket.capability:__queue_receive({ - aqara_device.id, - { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -1 } } - }) - - -- Should send GoToLiftPercentage with value 49 - test.socket.zigbee:__expect_send({ - aqara_device.id, - WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 49) - }) - end, - { min_api_version = 17 } -) - --- Test 9: Continuous step operation - multiple consecutive steps with mixed directions --- Uses LATEST_TARGET_LEVEL field to track state across multiple steps -test.register_coroutine_test( - "stateless_handler: stepShadeLevel - continuous step operations with mixed directions", - function() - -- Set initial state: device reports 50% - -- aqara handler emits windowShadeLevel and windowShade events - test.socket.zigbee:__queue_receive({ - aqara_device.id, - WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 50) - }) - -- aqara handler emits shade level and shade events - test.socket.capability:__expect_send({ - aqara_device.id, - { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } - }) - test.socket.capability:__expect_send({ - aqara_device.id, - { capability_id = "windowShade", component_id = "main", attribute_id = "windowShade", state = { value = "partially open" } } - }) - test.wait_for_events() - - -- Manually set LATEST_TARGET_LEVEL to simulate the reported value being used as current level - aqara_device:set_field("_latestTargetLevel", 50) - - -- Step 1: stepSize = 10 (up) - -- current_level = 50 (from LATEST_TARGET_LEVEL), step = 10 - -- ui_target_level = clamp(50 + 10, 0, 100) = 60 - -- device_target_level = 60 - test.socket.capability:__queue_receive({ - aqara_device.id, - { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } - }) - test.socket.zigbee:__expect_send({ - aqara_device.id, - WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 60) - }) - test.wait_for_events() - - -- Step 2: stepSize = 10 (up) - -- current_level = 60 (from LATEST_TARGET_LEVEL), step = 10 - -- ui_target_level = clamp(60 + 10, 0, 100) = 70 - -- device_target_level = 70 - test.socket.capability:__queue_receive({ - aqara_device.id, - { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } - }) - test.socket.zigbee:__expect_send({ - aqara_device.id, - WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 70) - }) - test.wait_for_events() - - -- Step 3: stepSize = -20 (down) - -- current_level = 70 (from LATEST_TARGET_LEVEL), step = -20 - -- ui_target_level = clamp(70 + (-20), 0, 100) = 50 - -- device_target_level = 50 - test.socket.capability:__queue_receive({ - aqara_device.id, - { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -20 } } - }) - test.socket.zigbee:__expect_send({ - aqara_device.id, - WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 50) - }) - test.wait_for_events() - - -- Step 4: stepSize = 15 (up) - -- current_level = 50 (from LATEST_TARGET_LEVEL), step = 15 - -- ui_target_level = clamp(50 + 15, 0, 100) = 65 - -- device_target_level = 65 - test.socket.capability:__queue_receive({ - aqara_device.id, - { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 15 } } - }) - test.socket.zigbee:__expect_send({ - aqara_device.id, - WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 65) - }) - test.wait_for_events() - - -- Step 5: stepSize = -5 (down) - -- current_level = 65 (from LATEST_TARGET_LEVEL), step = -5 - -- ui_target_level = clamp(65 + (-5), 0, 100) = 60 - -- device_target_level = 60 - test.socket.capability:__queue_receive({ - aqara_device.id, - { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -5 } } - }) - test.socket.zigbee:__expect_send({ - aqara_device.id, - WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 60) - }) - test.wait_for_events() - end, - { min_api_version = 17 } -) - -test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/window_shade_utils.lua b/drivers/SmartThings/zigbee-window-treatment/src/window_shade_utils.lua index f3e09c20a6..e2a80d65f7 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/window_shade_utils.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/window_shade_utils.lua @@ -1,6 +1,7 @@ -- Copyright 2025 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +local st_utils = require "st.utils" local capabilities = require "st.capabilities" local zcl_clusters = require "st.zigbee.zcl.clusters" @@ -9,6 +10,15 @@ local utils = {} utils.PRESET_LEVEL = 50 utils.PRESET_LEVEL_KEY = "_presetLevel" +-- LATEST_TARGET_LEVEL stores the latest target position during stateless step operations. +-- It is cleared after TARGET_LEVEL_TIME_OUT_SECONDS (15s) timeout to allow accumulated steps +-- within a short time frame while preventing indefinite accumulation. +local LATEST_TARGET_LEVEL = "_latestTargetLevel" +-- Timeout in seconds for the stateless step accumulation window +local TARGET_LEVEL_TIME_OUT_SECONDS = 15 +-- Field name for the timer that clears LATEST_TARGET_LEVEL after timeout +local TARGET_LEVEL_TIME_OUT = "_targetLevelTimeOut" + utils.get_preset_level = function(device, component) local level = device:get_latest_state(component, "windowShadePreset", "position") or device:get_field(utils.PRESET_LEVEL_KEY) or @@ -31,4 +41,36 @@ utils.set_preset_position_cmd = function(driver, device, command) device:set_field(utils.PRESET_LEVEL_KEY, command.args.position, {persist = true}) end +-- Step shade level handler for statelessWindowShadeLevelStep capability +utils.step_shade_level_handler = function() + return function(driver, device, command) + local step = command.args.stepSize or 0 + if step == 0 then return end + + -- Step from the latest target level if it exists, or from the current shade level + local latest_target_level = device:get_field(LATEST_TARGET_LEVEL) or + device:get_latest_state("main", capabilities.windowShadeLevel.ID, capabilities.windowShadeLevel.shadeLevel.NAME, 0) + local new_target_level = st_utils.clamp_value(latest_target_level + step, 0, 100) + + -- Cancel any previous timer and set a 15 second timeout timer to ensure LATEST_TARGET_LEVEL is cleared + -- This is to prevent the stateless step from accumulating indefinitely, while ensuring that a single + -- "scroll" action can accumulate multiple steps within a short time frame + if device:get_field(TARGET_LEVEL_TIME_OUT) then + device.thread:cancel_timer(device:get_field(TARGET_LEVEL_TIME_OUT)) + end + local timer = device.thread:call_with_delay(TARGET_LEVEL_TIME_OUT_SECONDS, function(d) + device:set_field(LATEST_TARGET_LEVEL, nil) + end) + device:set_field(TARGET_LEVEL_TIME_OUT, timer) + device:set_field(LATEST_TARGET_LEVEL, new_target_level) + + driver:inject_capability_command(device, { + capability = capabilities.windowShadeLevel.ID, + component = command.component, + command = capabilities.windowShadeLevel.commands.setShadeLevel.NAME, + named_args = { shadeLevel = new_target_level } + }) + end +end + return utils