diff --git a/drivers/SmartThings/zigbee-window-treatment/capabilities/statelessWindowShadeLevelStep.yaml b/drivers/SmartThings/zigbee-window-treatment/capabilities/statelessWindowShadeLevelStep.yaml new file mode 100644 index 0000000000..69ba89c184 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/capabilities/statelessWindowShadeLevelStep.yaml @@ -0,0 +1,16 @@ +id: stse.statelessWindowShadeLevelStep +version: 1 +status: proposed +name: stateless window shade level step +ephemeral: false +attributes: {} +commands: + stepShadeLevel: + name: stepShadeLevel + arguments: + - name: stepSize + optional: false + schema: + type: integer + minimum: -100 + maximum: 100 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..9dd0afeb48 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 @@ -17,4 +19,4 @@ components: - id: firmwareUpdate version: 1 categories: - - name: Blind \ No newline at end of file + - name: Blind 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..1a1e4bfeda 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,7 +8,9 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessWindowShadeLevelStep + version: 1 - id: refresh version: 1 categories: - - name: Blind \ No newline at end of file + - name: Blind 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/aqara/aqara_utils.lua b/drivers/SmartThings/zigbee-window-treatment/src/aqara/aqara_utils.lua index 9ecfc6a605..bea714e838 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/aqara/aqara_utils.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/aqara/aqara_utils.lua @@ -31,11 +31,14 @@ local function shade_level_cmd(driver, device, command) end level = utils.round(level) - -- update ui to the new level + -- Invert percentage: 100-level, so user's 100% means closed, 0% means open + local inverted_level = 100 - level + + -- update ui to the new level device:emit_event(capabilities.windowShadeLevel.shadeLevel(level)) - -- send - device:send_to_component(command.component, WindowCovering.server.commands.GoToLiftPercentage(device, level)) + -- send (send inverted value to device) + device:send_to_component(command.component, WindowCovering.server.commands.GoToLiftPercentage(device, inverted_level)) end local function emit_shade_event_by_state(device, value) @@ -82,8 +85,11 @@ local function emit_shade_level_event(device, value) end level = utils.round(level) - -- update level ui - device:emit_event(capabilities.windowShadeLevel.shadeLevel(level)) + -- Invert percentage: 100-level, invert device-reported value for UI display + local inverted_level = 100 - level + + -- update level ui (display inverted value) + device:emit_event(capabilities.windowShadeLevel.shadeLevel(inverted_level)) end aqara_utils.PRIVATE_CLUSTER_ID = PRIVATE_CLUSTER_ID 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/brand_configs.lua b/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/brand_configs.lua new file mode 100644 index 0000000000..e9e31ccd5a --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/brand_configs.lua @@ -0,0 +1,96 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Brand-specific configuration for window treatment devices +-- Supports statelessWindowShadeLevelStep capability + +local BRAND_CONFIGS = { + -- invert_level = true: Device reports inverted values (0 = open, 100 = closed) + -- When sending command: device_target_level = 100 - ui_target_level + -- When receiving report: ui_reported_level = 100 - reported_level + { + name = "aqara", + mfr = "LUMI", + models = { "lumi.curtain", "lumi.curtain.v1", "lumi.curtain.aq2", "lumi.curtain.agl001", "lumi.curtain.acn002" }, + invert_level = true, + }, + { + name = "somfy", + mfr = "Somfy", + models = {}, -- Empty models = match any model from this manufacturer + invert_level = true, + }, + { + name = "vimar", + mfr = "VIMAR", + models = {}, + invert_level = true, + }, + { + name = "invert-lift-percentage", + mfr = "IKEA of Sweden", + models = {}, + invert_level = true, + }, + { + name = "invert-lift-percentage", + mfr = "Smartwings", + models = {}, + invert_level = true, + }, + { + name = "invert-lift-percentage", + mfr = "Insta GmbH", + models = {}, + invert_level = true, + }, + { + name = "yoolax", + mfr = "Yookee", + models = { "D10110" }, + invert_level = true, + }, + { + name = "yoolax", + mfr = "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 = "hanssem", + mfr = "", + models = { "TS0601" }, + match_by_model = true, -- Match by model only, not manufacturer + use_tuya_cluster = true, -- Use Tuya custom cluster 0xEF00 + }, + { + name = "rooms-beautiful", + mfr = "Rooms Beautiful", + models = { "C001" }, + }, + { + name = "screen-innovations", + mfr = "", + models = { "WM25/L-Z" }, + match_by_model = true, + }, + -- Note: HOPOsmart and VIVIDSTORM are NOT supported (use custom clusters) +} + +return BRAND_CONFIGS 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/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/init.lua new file mode 100644 index 0000000000..ae1cd19bbb --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/init.lua @@ -0,0 +1,156 @@ +-- 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 BRAND_CONFIGS = require "stateless_handler.brand_configs" +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 + +-- Field keys for tracking target level and timeout timer +local LATEST_TARGET_LEVEL = "__latest_target_level" + +-- Get brand configuration for a device +local function get_brand_config(device) + local manufacturer = device:get_manufacturer() or "" + local model = device:get_model() or "" + + for _, config in ipairs(BRAND_CONFIGS) do + -- Check if this config matches by model only (match_by_model = true) + if config.match_by_model then + -- Match model only (case-insensitive) + for _, model_pattern in ipairs(config.models) do + if string.lower(model) == string.lower(model_pattern) then + return config + end + end + else + -- Match manufacturer (case-insensitive) + if string.lower(manufacturer) == string.lower(config.mfr) 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 substring match) + for _, model_pattern in ipairs(config.models) do + if string.find(string.lower(model), string.lower(model_pattern), 1, true) then + return config + end + end + end + end + end + return nil -- No matching brand, use default behavior +end + +-- Step shade level handler for statelessWindowShadeLevelStep capability +local function step_shade_level_handler(driver, device, command) + -- Get brand-specific configuration + local brand_config = get_brand_config(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 + + -- Priority: use target_level if exists, otherwise use latest state + 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 UI target_level (user's expected percentage) + local ui_target_level= utils.clamp_value(current_level + step, 0, 100) + + ui_target_level = utils.round(ui_target_level) + + -- Apply brand-specific inversion if needed + local device_target_level = ui_target_level + if brand_config and brand_config.invert_level then + device_target_level = 100 - ui_target_level + end + + -- Set target_level for tracking (store UI value) + device:set_field(LATEST_TARGET_LEVEL, ui_target_level) + + -- Send command based on brand configuration + if brand_config and brand_config.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 brand_config and brand_config.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) + local latest_target_level = device:get_field(LATEST_TARGET_LEVEL) + if latest_target_level ~= nil then + -- Device reached target position, clear target marker and timeout timer + device:set_field(LATEST_TARGET_LEVEL, nil) + 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 = { + [clusters.WindowCovering.ID] = { + [clusters.WindowCovering.attributes.CurrentPositionLiftPercentage.ID] = shade_level_report_handler, + }, + [clusters.Level.ID] = { + [clusters.Level.attributes.CurrentLevel.ID] = shade_level_report_handler, + }, + [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..035e076b88 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/sub_drivers.lua @@ -15,5 +15,7 @@ local sub_drivers = { lazy_load_if_possible("screen-innovations"), lazy_load_if_possible("VIVIDSTORM"), lazy_load_if_possible("HOPOsmart"), + -- Generic stateless handlers (must be after vendor-specific drivers) + 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..8df45cdc43 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_stateless_handler.lua @@ -0,0 +1,398 @@ +-- 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 + +-- Register the statelessWindowShadeLevelStep capability for testing +test.add_package_capability("statelessWindowShadeLevelStep.yaml") + +-- Create mock Aqara curtain device +-- 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-aqara.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) +test.register_coroutine_test( + "stateless_handler: stepShadeLevel - value clamped to 100", + function() + -- Set initial state: device=10% -> UI = 100 - 10 = 90% + test.socket.zigbee:__queue_receive({ + aqara_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 10) + }) + 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() + + -- Send stepShadeLevel command with stepSize = 50 (would exceed 100) + -- Expected: clamp(90 + 50, 0, 100) = 100 UI, device = 100 - 100 = 0 + test.socket.capability:__queue_receive({ + aqara_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 50 } } + }) + + -- Should send GoToLiftPercentage with value 0 (clamped and inverted) + test.socket.zigbee:__expect_send({ + aqara_device.id, + WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 0) + }) + end, + { min_api_version = 17 } +) + +-- Test 3: Boundary condition - value clamped to 0 (UI, negative step) +test.register_coroutine_test( + "stateless_handler: stepShadeLevel - value clamped to 0", + function() + -- Set initial state: device=90% -> UI = 100 - 90 = 10% + test.socket.zigbee:__queue_receive({ + aqara_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 90) + }) + 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() + + -- Send stepShadeLevel command with stepSize = -20 (would go below 0) + -- Expected: clamp(10 + (-20), 0, 100) = 0 UI, device = 100 - 0 = 100 + test.socket.capability:__queue_receive({ + aqara_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -20 } } + }) + + -- Should send GoToLiftPercentage with value 100 (clamped and inverted) + test.socket.zigbee:__expect_send({ + aqara_device.id, + WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 100) + }) + end, + { min_api_version = 17 } +) + +-- Test 4: Normal step up operation +test.register_coroutine_test( + "stateless_handler: stepShadeLevel - normal step up", + function() + -- Set initial state: device=70% -> UI = 100 - 70 = 30% + test.socket.zigbee:__queue_receive({ + aqara_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 70) + }) + 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() + + -- Send stepShadeLevel command with stepSize = 10 + -- Expected: 30 + 10 = 40 UI, device = 100 - 40 = 60 + test.socket.capability:__queue_receive({ + aqara_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 } } + }) + + -- 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 5: Normal step down operation +test.register_coroutine_test( + "stateless_handler: stepShadeLevel - normal step down", + function() + -- Set initial state: device=30% -> UI = 100 - 30 = 70% + test.socket.zigbee:__queue_receive({ + aqara_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 30) + }) + 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() + + -- Send stepShadeLevel command with stepSize = -20 + -- Expected: 70 + (-20) = 50 UI, device = 100 - 50 = 50 + test.socket.capability:__queue_receive({ + aqara_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -20 } } + }) + + -- Should send GoToLiftPercentage with value 50 + test.socket.zigbee:__expect_send({ + aqara_device.id, + WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 50) + }) + 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 + test.socket.zigbee:__queue_receive({ + aqara_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 50) + }) + 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 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) +test.register_coroutine_test( + "stateless_handler: stepShadeLevel - minimum stepSize 1 works correctly", + function() + -- Set initial state: device=50% -> UI = 100 - 50 = 50% + test.socket.zigbee:__queue_receive({ + aqara_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 50) + }) + 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 = 1 (minimum positive step) + -- Expected: 50 + 1 = 51 UI, device = 100 - 51 = 49 + 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 8: Boundary condition - minimum negative stepSize (stepSize = -1) +test.register_coroutine_test( + "stateless_handler: stepShadeLevel - minimum negative stepSize -1 works correctly", + function() + -- Set initial state: device=50% -> UI = 100 - 50 = 50% + test.socket.zigbee:__queue_receive({ + aqara_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 50) + }) + 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 = -1 (minimum negative step) + -- Expected: 50 + (-1) = 49 UI, device = 100 - 49 = 51 + 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 9: Continuous step operation - multiple consecutive steps with mixed directions +test.register_coroutine_test( + "stateless_handler: stepShadeLevel - continuous step operations with mixed directions", + function() + -- Set initial state: device=50% -> UI = 100 - 50 = 50% + test.socket.zigbee:__queue_receive({ + aqara_device.id, + WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 50) + }) + 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() + + -- Step 1: stepSize = 10 (up), Expected: 50 + 10 = 60 UI, device = 100 - 60 = 40 + 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, 40) + }) + test.wait_for_events() + + -- Step 2: stepSize = 10 (up), Expected: 60 + 10 = 70 UI, device = 100 - 70 = 30 + 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, 30) + }) + test.wait_for_events() + + -- Step 3: stepSize = -20 (down), Expected: 70 + (-20) = 50 UI, device = 100 - 50 = 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), Expected: 50 + 15 = 65 UI, device = 100 - 65 = 35 + 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, 35) + }) + test.wait_for_events() + + -- Step 5: stepSize = -5 (down), Expected: 65 + (-5) = 60 UI, device = 100 - 60 = 40 + 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, 40) + }) + test.wait_for_events() + end, + { min_api_version = 17 } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zigbee-window-treatment/src/tuya_utils/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/tuya_utils/init.lua new file mode 100644 index 0000000000..e69de29bb2