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/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/sub_drivers.lua b/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua index 959c8d8c22..5d1ed84adb 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua @@ -3,14 +3,10 @@ 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"), 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..5350d80bd2 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_stateless_handler.lua @@ -0,0 +1,518 @@ +-- 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 = 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 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, 60) + }) + -- aqara handler emits shade level and shade events + -- 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 = 60 } } + }) + 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", 60) + + -- Send stepShadeLevel command with stepSize = 50 + -- 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 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, 100) + }) + end, + { min_api_version = 17 } +) + +-- Test 3: Boundary condition - value clamped to 0 (UI, negative step) +-- 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 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 + 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 + aqara_device:set_field("_latestTargetLevel", 10) + + -- Send stepShadeLevel command with stepSize = -20 + -- 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 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, 0) + }) + 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 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) + }) + 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 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) + }) + 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 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) + }) + 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 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) + }) + 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.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) + }) + 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.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) + }) + 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.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) + }) + 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.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) + }) + 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.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) + }) + 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..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