From 4818175f216e09a51dc8fed9694c6dc07e3fa32f Mon Sep 17 00:00:00 2001 From: zs Date: Thu, 9 Jul 2026 18:40:40 +0800 Subject: [PATCH 1/3] 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 04af865cca314f819d107c236fe5cc5bfd0ed8b7 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Mon, 13 Jul 2026 18:53:23 -0500 Subject: [PATCH 2/3] Handle Zigbee Window Stateless Step Commands --- .../zigbee-window-treatment/src/init.lua | 5 +- .../src/invert-lift-percentage/can_handle.lua | 14 -- .../src/inverted-lift/can_handle.lua | 22 +++ .../init.lua | 27 +-- .../rooms-beautiful/can_handle.lua | 4 +- .../rooms-beautiful/fingerprints.lua | 0 .../rooms-beautiful/init.lua | 2 +- .../{ => inverted-lift}/somfy/can_handle.lua | 4 +- .../somfy/fingerprints.lua | 0 .../src/{ => inverted-lift}/somfy/init.lua | 2 +- .../src/inverted-lift/sub_drivers.lua | 11 ++ .../{ => inverted-lift}/vimar/can_handle.lua | 4 +- .../vimar/fingerprints.lua | 0 .../src/{ => inverted-lift}/vimar/init.lua | 2 +- .../{ => inverted-lift}/yoolax/can_handle.lua | 4 +- .../yoolax/fingerprints.lua | 0 .../src/{ => inverted-lift}/yoolax/init.lua | 2 +- .../src/stateless_handler/can_handle.lua | 13 -- .../src/stateless_handler/fingerprints.lua | 79 -------- .../src/stateless_handler/init.lua | 178 ------------------ .../src/sub_drivers.lua | 7 +- ...bee_window_treatment_stateless_handler.lua | 15 ++ .../src/window_shade_utils.lua | 42 +++++ 23 files changed, 123 insertions(+), 314 deletions(-) delete mode 100644 drivers/SmartThings/zigbee-window-treatment/src/invert-lift-percentage/can_handle.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/can_handle.lua rename drivers/SmartThings/zigbee-window-treatment/src/{invert-lift-percentage => inverted-lift}/init.lua (78%) rename drivers/SmartThings/zigbee-window-treatment/src/{ => inverted-lift}/rooms-beautiful/can_handle.lua (72%) rename drivers/SmartThings/zigbee-window-treatment/src/{ => inverted-lift}/rooms-beautiful/fingerprints.lua (100%) rename drivers/SmartThings/zigbee-window-treatment/src/{ => inverted-lift}/rooms-beautiful/init.lua (98%) rename drivers/SmartThings/zigbee-window-treatment/src/{ => inverted-lift}/somfy/can_handle.lua (75%) rename drivers/SmartThings/zigbee-window-treatment/src/{ => inverted-lift}/somfy/fingerprints.lua (100%) rename drivers/SmartThings/zigbee-window-treatment/src/{ => inverted-lift}/somfy/init.lua (98%) create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/sub_drivers.lua rename drivers/SmartThings/zigbee-window-treatment/src/{ => inverted-lift}/vimar/can_handle.lua (75%) rename drivers/SmartThings/zigbee-window-treatment/src/{ => inverted-lift}/vimar/fingerprints.lua (100%) rename drivers/SmartThings/zigbee-window-treatment/src/{ => inverted-lift}/vimar/init.lua (99%) rename drivers/SmartThings/zigbee-window-treatment/src/{ => inverted-lift}/yoolax/can_handle.lua (75%) rename drivers/SmartThings/zigbee-window-treatment/src/{ => inverted-lift}/yoolax/fingerprints.lua (100%) rename drivers/SmartThings/zigbee-window-treatment/src/{ => inverted-lift}/yoolax/init.lua (99%) 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 diff --git a/drivers/SmartThings/zigbee-window-treatment/src/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/init.lua index 0a093645f8..43a46ad71d 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/init.lua @@ -40,7 +40,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/invert-lift-percentage/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/invert-lift-percentage/can_handle.lua deleted file mode 100644 index 69cfd4393a..0000000000 --- a/drivers/SmartThings/zigbee-window-treatment/src/invert-lift-percentage/can_handle.lua +++ /dev/null @@ -1,14 +0,0 @@ --- Copyright 2025 SmartThings, Inc. --- Licensed under the Apache License, Version 2.0 - -local function invert_lift_percentage_can_handle(opts, driver, device, ...) - if device:get_manufacturer() == "IKEA of Sweden" or - device:get_manufacturer() == "Smartwings" or - device:get_manufacturer() == "Insta GmbH" - then - return true, require("invert-lift-percentage") - end - return false -end - -return invert_lift_percentage_can_handle diff --git a/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/can_handle.lua new file mode 100644 index 0000000000..fe37292be4 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/can_handle.lua @@ -0,0 +1,22 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function invert_lift_percentage_can_handle(opts, driver, device, ...) + local somfy_can_handle = require("inverted-lift.somfy.can_handle") + local yoolax_can_handle = require("inverted-lift.yoolax.can_handle") + local rooms_beautiful_can_handle = require("inverted-lift.rooms-beautiful.can_handle") + local vimar_can_handle = require("inverted-lift.vimar.can_handle") + if somfy_can_handle(opts, driver, device) + or yoolax_can_handle(opts, driver, device) + or rooms_beautiful_can_handle(opts, driver, device) + or vimar_can_handle(opts, driver, device) + or device:get_manufacturer() == "IKEA of Sweden" + or device:get_manufacturer() == "Smartwings" + or device:get_manufacturer() == "Insta GmbH" + then + return true, require("inverted-lift") + end + return false +end + +return invert_lift_percentage_can_handle diff --git a/drivers/SmartThings/zigbee-window-treatment/src/invert-lift-percentage/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/init.lua similarity index 78% rename from drivers/SmartThings/zigbee-window-treatment/src/invert-lift-percentage/init.lua rename to drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/init.lua index b586459b9a..d90c8c0eae 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/invert-lift-percentage/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/init.lua @@ -56,21 +56,21 @@ local function current_position_attr_handler(driver, device, value, zb_rx) end end -local function set_shade_level(device, value, command) +local function set_shade_level_helper(device, value, command) local level = 100 - value device:send_to_component(command.component, WindowCovering.server.commands.GoToLiftPercentage(device, level)) end -local function window_shade_level_cmd(driver, device, command) - set_shade_level(device, command.args.shadeLevel, command) +local function set_shade_level_handler(driver, device, command) + set_shade_level_helper(device, command.args.shadeLevel, command) end -local function window_shade_preset_cmd(driver, device, command) +local function preset_position_handler(driver, device, command) local level = window_shade_utils.get_preset_level(device, command.component) - set_shade_level(device, level, command) + set_shade_level_helper(device, level, command) end -local ikea_window_treatment = { +local inverted_window_treatment = { NAME = "inverted lift percentage", zigbee_handlers = { attr = { @@ -80,14 +80,19 @@ local ikea_window_treatment = { } }, capability_handlers = { + [capabilities.statelessWindowShadeLevelStep.ID] = { + [capabilities.statelessWindowShadeLevelStep.commands.stepShadeLevel.NAME] = + window_shade_utils.step_shade_level_handler(true) -- invert_shade_level is true for devices under this subdriver + }, [capabilities.windowShadeLevel.ID] = { - [capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = window_shade_level_cmd + [capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = set_shade_level_handler }, [capabilities.windowShadePreset.ID] = { - [capabilities.windowShadePreset.commands.presetPosition.NAME] = window_shade_preset_cmd - } + [capabilities.windowShadePreset.commands.presetPosition.NAME] = preset_position_handler + }, }, - can_handle = require("invert-lift-percentage.can_handle"), + can_handle = require("inverted-lift.can_handle"), + sub_drivers = require("inverted-lift.sub_drivers") } -return ikea_window_treatment +return inverted_window_treatment diff --git a/drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/rooms-beautiful/can_handle.lua similarity index 72% rename from drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/can_handle.lua rename to drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/rooms-beautiful/can_handle.lua index 6bc25d2f91..2fea4af0f5 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/can_handle.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/rooms-beautiful/can_handle.lua @@ -2,10 +2,10 @@ -- Licensed under the Apache License, Version 2.0 local is_zigbee_window_shade = function(opts, driver, device) - local FINGERPRINTS = require("rooms-beautiful.fingerprints") + local FINGERPRINTS = require("inverted-lift.rooms-beautiful.fingerprints") for _, fingerprint in ipairs(FINGERPRINTS) do if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true, require("rooms-beautiful") + return true, require("inverted-lift.rooms-beautiful") end end return false diff --git a/drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/rooms-beautiful/fingerprints.lua similarity index 100% rename from drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/fingerprints.lua rename to drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/rooms-beautiful/fingerprints.lua diff --git a/drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/rooms-beautiful/init.lua similarity index 98% rename from drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/init.lua rename to drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/rooms-beautiful/init.lua index bb868a8716..1bbf8c8f33 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/rooms-beautiful/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/rooms-beautiful/init.lua @@ -108,7 +108,7 @@ local rooms_beautiful_handler = { init = battery_defaults.build_linear_voltage_init(2.5, 3.0), infoChanged = info_changed }, - can_handle = require("rooms-beautiful.can_handle"), + can_handle = require("inverted-lift.rooms-beautiful.can_handle"), } return rooms_beautiful_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/somfy/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/somfy/can_handle.lua similarity index 75% rename from drivers/SmartThings/zigbee-window-treatment/src/somfy/can_handle.lua rename to drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/somfy/can_handle.lua index 27a6c83ca3..3b470dfae1 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/somfy/can_handle.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/somfy/can_handle.lua @@ -2,10 +2,10 @@ -- Licensed under the Apache License, Version 2.0 local is_zigbee_window_shade = function(opts, driver, device) - local FINGERPRINTS = require("somfy.fingerprints") + local FINGERPRINTS = require("inverted-lift.somfy.fingerprints") for _, fingerprint in ipairs(FINGERPRINTS) do if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true, require("somfy") + return true, require("inverted-lift.somfy") end end return false diff --git a/drivers/SmartThings/zigbee-window-treatment/src/somfy/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/somfy/fingerprints.lua similarity index 100% rename from drivers/SmartThings/zigbee-window-treatment/src/somfy/fingerprints.lua rename to drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/somfy/fingerprints.lua diff --git a/drivers/SmartThings/zigbee-window-treatment/src/somfy/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/somfy/init.lua similarity index 98% rename from drivers/SmartThings/zigbee-window-treatment/src/somfy/init.lua rename to drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/somfy/init.lua index da416ba9ea..ba9a1644ad 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/somfy/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/somfy/init.lua @@ -109,7 +109,7 @@ local somfy_handler = { } } }, - can_handle = require("somfy.can_handle"), + can_handle = require("inverted-lift.somfy.can_handle"), } return somfy_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/sub_drivers.lua b/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/sub_drivers.lua new file mode 100644 index 0000000000..745fb6a203 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/sub_drivers.lua @@ -0,0 +1,11 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("inverted-lift.vimar"), + lazy_load_if_possible("inverted-lift.yoolax"), + lazy_load_if_possible("inverted-lift.rooms-beautiful"), + lazy_load_if_possible("inverted-lift.somfy"), +} +return sub_drivers diff --git a/drivers/SmartThings/zigbee-window-treatment/src/vimar/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/vimar/can_handle.lua similarity index 75% rename from drivers/SmartThings/zigbee-window-treatment/src/vimar/can_handle.lua rename to drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/vimar/can_handle.lua index 1d72817eae..80c5406f1e 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/vimar/can_handle.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/vimar/can_handle.lua @@ -2,10 +2,10 @@ -- Licensed under the Apache License, Version 2.0 local is_zigbee_window_shade = function(opts, driver, device) - local FINGERPRINTS = require("vimar.fingerprints") + local FINGERPRINTS = require("inverted-lift.vimar.fingerprints") for _, fingerprint in ipairs(FINGERPRINTS) do if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true, require("vimar") + return true, require("inverted-lift.vimar") end end return false diff --git a/drivers/SmartThings/zigbee-window-treatment/src/vimar/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/vimar/fingerprints.lua similarity index 100% rename from drivers/SmartThings/zigbee-window-treatment/src/vimar/fingerprints.lua rename to drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/vimar/fingerprints.lua diff --git a/drivers/SmartThings/zigbee-window-treatment/src/vimar/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/vimar/init.lua similarity index 99% rename from drivers/SmartThings/zigbee-window-treatment/src/vimar/init.lua rename to drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/vimar/init.lua index dd5ea15aed..c56b8e09f7 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/vimar/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/vimar/init.lua @@ -154,7 +154,7 @@ local vimar_handler = { lifecycle_handlers = { init = device_init }, - can_handle = require("vimar.can_handle"), + can_handle = require("inverted-lift.vimar.can_handle"), } return vimar_handler diff --git a/drivers/SmartThings/zigbee-window-treatment/src/yoolax/can_handle.lua b/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/yoolax/can_handle.lua similarity index 75% rename from drivers/SmartThings/zigbee-window-treatment/src/yoolax/can_handle.lua rename to drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/yoolax/can_handle.lua index 006fa3e1bb..2de8928c14 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/yoolax/can_handle.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/yoolax/can_handle.lua @@ -2,10 +2,10 @@ -- Licensed under the Apache License, Version 2.0 local function is_yoolax_window_shade(opts, driver, device) - local FINGERPRINTS = require("yoolax.fingerprints") + local FINGERPRINTS = require("inverted-lift.yoolax.fingerprints") for _, fingerprint in ipairs(FINGERPRINTS) do if device:get_manufacturer() == fingerprint.mfr and device:get_model() == fingerprint.model then - return true, require("yoolax") + return true, require("inverted-lift.yoolax") end end return false diff --git a/drivers/SmartThings/zigbee-window-treatment/src/yoolax/fingerprints.lua b/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/yoolax/fingerprints.lua similarity index 100% rename from drivers/SmartThings/zigbee-window-treatment/src/yoolax/fingerprints.lua rename to drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/yoolax/fingerprints.lua diff --git a/drivers/SmartThings/zigbee-window-treatment/src/yoolax/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/yoolax/init.lua similarity index 99% rename from drivers/SmartThings/zigbee-window-treatment/src/yoolax/init.lua rename to drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/yoolax/init.lua index 5a593cdf2c..a705b94b44 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/yoolax/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/inverted-lift/yoolax/init.lua @@ -138,7 +138,7 @@ local yoolax_window_shade = { } }, }, - can_handle = require("yoolax.can_handle"), + can_handle = require("inverted-lift.yoolax.can_handle"), } return yoolax_window_shade 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..5d1ed84adb 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua @@ -3,18 +3,13 @@ local lazy_load_if_possible = require "lazy_load_subdriver" local sub_drivers = { - lazy_load_if_possible("vimar"), lazy_load_if_possible("aqara"), lazy_load_if_possible("feibit"), - lazy_load_if_possible("somfy"), - lazy_load_if_possible("invert-lift-percentage"), - lazy_load_if_possible("rooms-beautiful"), + lazy_load_if_possible("inverted-lift"), lazy_load_if_possible("axis"), - lazy_load_if_possible("yoolax"), lazy_load_if_possible("hanssem"), 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 index 894cc1f81c..81a8fd5de5 100644 --- 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 @@ -417,6 +417,9 @@ test.register_coroutine_test( aqara_device.id, { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } }) + test.socket.capability:__expect_send( + aqara_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(60)) + ) test.socket.zigbee:__expect_send({ aqara_device.id, WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 60) @@ -431,6 +434,9 @@ test.register_coroutine_test( aqara_device.id, { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } }) + test.socket.capability:__expect_send( + aqara_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(70)) + ) test.socket.zigbee:__expect_send({ aqara_device.id, WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 70) @@ -445,6 +451,9 @@ test.register_coroutine_test( aqara_device.id, { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -20 } } }) + test.socket.capability:__expect_send( + aqara_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(50)) + ) test.socket.zigbee:__expect_send({ aqara_device.id, WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 50) @@ -459,6 +468,9 @@ test.register_coroutine_test( aqara_device.id, { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 15 } } }) + test.socket.capability:__expect_send( + aqara_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(65)) + ) test.socket.zigbee:__expect_send({ aqara_device.id, WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 65) @@ -473,6 +485,9 @@ test.register_coroutine_test( aqara_device.id, { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -5 } } }) + test.socket.capability:__expect_send( + aqara_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(60)) + ) test.socket.zigbee:__expect_send({ aqara_device.id, WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 60) 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..9fae7f8f7a 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,14 @@ local utils = {} utils.PRESET_LEVEL = 50 utils.PRESET_LEVEL_KEY = "_presetLevel" +-- 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" +-- Timeout for the stateless step to accumulate +local TARGET_LEVEL_TIME_OUT_SECONDS = 3 +-- Field name for the timer that clears the target position 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 +40,37 @@ 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(invert_shade_level) + return function(driver, device, command) + local step = command.args.stepSize or 0 + if step == 0 then return + elseif invert_shade_level then step = -step end -- invert step's direction if invert_shade_level is true + + -- 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 3 second timeout timer to ensure 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 From 77a449f5634fec15a36a21e10150af2c61ae0a25 Mon Sep 17 00:00:00 2001 From: zs Date: Tue, 14 Jul 2026 21:02:36 +0800 Subject: [PATCH 3/3] Update TC for Handle Zigbee Window Stateless Step Commands --- ...bee_window_treatment_stateless_handler.lua | 76 ++++++++++++------- 1 file changed, 47 insertions(+), 29 deletions(-) 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 index 81a8fd5de5..5350d80bd2 100644 --- 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 @@ -77,23 +77,23 @@ test.register_coroutine_test( -- 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) +-- current_level = 60 (device report), step = 50 +-- ui_target_level = clamp(60 + 50, 0, 100) = 100 (clamped) +-- device_target_level = 100 (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% + -- Set initial state: device reports 60% -- 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) + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 60) }) -- aqara handler emits shade level and shade events - -- level 10 is partially open (only 0 = closed, 100 = open) + -- level 60 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 } } + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 60 } } }) test.socket.capability:__expect_send({ aqara_device.id, @@ -103,43 +103,46 @@ test.register_coroutine_test( -- 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) + aqara_device:set_field("_latestTargetLevel", 60) -- 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) + -- current_level = 60 (from LATEST_TARGET_LEVEL), step = 50 + -- ui_target_level = clamp(60 + 50, 0, 100) = 100 (clamped to max) + -- device_target_level = 100 (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 + -- Should emit capability event and send GoToLiftPercentage with value 100 (clamped) + test.socket.capability:__expect_send( + aqara_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(100)) + ) test.socket.zigbee:__expect_send({ aqara_device.id, - WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 60) + WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 100) }) 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) +-- current_level = 10 (device report), step = -20 +-- ui_target_level = clamp(10 + (-20), 0, 100) = 0 (clamped) +-- device_target_level = 0 (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% + -- 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, 90) + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 10) }) -- 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 } } + { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 10 } } }) test.socket.capability:__expect_send({ aqara_device.id, @@ -148,21 +151,24 @@ test.register_coroutine_test( 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) + aqara_device:set_field("_latestTargetLevel", 10) -- 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) + -- current_level = 10, step = -20 + -- ui_target_level = clamp(10 + (-20), 0, 100) = 0 (clamped to min) + -- device_target_level = 0 (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 + -- Should emit capability event and send GoToLiftPercentage with value 0 (clamped) + test.socket.capability:__expect_send( + aqara_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(0)) + ) test.socket.zigbee:__expect_send({ aqara_device.id, - WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 70) + WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 0) }) end, { min_api_version = 17 } @@ -204,7 +210,10 @@ test.register_coroutine_test( { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } }) - -- Should send GoToLiftPercentage with value 80 + -- Should emit capability event and send GoToLiftPercentage with value 80 + test.socket.capability:__expect_send( + aqara_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(80)) + ) test.socket.zigbee:__expect_send({ aqara_device.id, WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 80) @@ -249,7 +258,10 @@ test.register_coroutine_test( { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -20 } } }) - -- Should send GoToLiftPercentage with value 10 + -- Should emit capability event and send GoToLiftPercentage with value 10 + test.socket.capability:__expect_send( + aqara_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(10)) + ) test.socket.zigbee:__expect_send({ aqara_device.id, WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 10) @@ -330,7 +342,10 @@ test.register_coroutine_test( { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 1 } } }) - -- Should send GoToLiftPercentage with value 51 + -- Should emit capability event and send GoToLiftPercentage with value 51 + test.socket.capability:__expect_send( + aqara_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(51)) + ) test.socket.zigbee:__expect_send({ aqara_device.id, WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 51) @@ -375,7 +390,10 @@ test.register_coroutine_test( { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -1 } } }) - -- Should send GoToLiftPercentage with value 49 + -- Should emit capability event and send GoToLiftPercentage with value 49 + test.socket.capability:__expect_send( + aqara_device:generate_test_message("main", capabilities.windowShadeLevel.shadeLevel(49)) + ) test.socket.zigbee:__expect_send({ aqara_device.id, WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 49)