From 0c9888992ebf701d61ea9d1d2c6f38e17885a9fc Mon Sep 17 00:00:00 2001 From: zs Date: Wed, 8 Jul 2026 19:15:10 +0800 Subject: [PATCH 1/5] add stateless_handler --- .../profiles/window-treatment-aqara.yml | 2 + .../src/aqara/aqara_utils.lua | 16 +- .../src/stateless_handler/brand_configs.lua | 95 +++++++++ .../src/stateless_handler/can_handle.lua | 13 ++ .../src/stateless_handler/init.lua | 182 ++++++++++++++++++ .../src/sub_drivers.lua | 2 + 6 files changed, 305 insertions(+), 5 deletions(-) create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/brand_configs.lua 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/init.lua 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/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/stateless_handler/brand_configs.lua b/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/brand_configs.lua new file mode 100644 index 0000000000..57b3fe7984 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/brand_configs.lua @@ -0,0 +1,95 @@ +-- 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 + }, + { + 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..d4cc3fb036 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/init.lua @@ -0,0 +1,182 @@ +-- 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" + +-- Field keys for tracking target level and timeout timer +local LATEST_TARGET_LEVEL = "__latest_target_level" +local TARGET_LEVEL_TIME_OUT = "__target_level_timeout" +local TARGET_LEVEL_TIME_OUT_SECONDS = 30 + +-- 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 + for _, model_pattern in ipairs(config.models) do + if string.find(model, 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 = current_level + step + + if ui_target_level > 100 then + ui_target_level = 100 + elseif ui_target_level < 0 then + ui_target_level = 0 + end + 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) + + -- Cancel previous timeout timer if exists + local old_timer = device:get_field(TARGET_LEVEL_TIME_OUT) + if old_timer ~= nil then + device.thread:cancel_timer(old_timer) + end + + -- Set timeout timer to ensure target_level is cleared after operation completes + local timer = device.thread:call_with_delay(TARGET_LEVEL_TIME_OUT_SECONDS, function(d) + device:set_field(LATEST_TARGET_LEVEL, nil) + device:set_field(TARGET_LEVEL_TIME_OUT, nil) + end) + device:set_field(TARGET_LEVEL_TIME_OUT, timer) + + -- Send command based on brand configuration + if brand_config and brand_config.use_level_cluster then + -- Feibit 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)) + else + -- Standard: use WindowCovering.GoToLiftPercentage + device:send_to_component(command.component, clusters.WindowCovering.server.commands.GoToLiftPercentage(device, device_target_level)) + end +end + +-- Handle device position report from WindowCovering cluster, Level cluster, or AnalogOutput cluster +-- Parameters: +-- reported_level: The raw level value from the device (0-100 percentage) +-- zb_rx: The zigbee receive message for endpoint info +local function shade_level_report_handler(driver, device, reported_level, zb_rx) + local latest_target_level = device:get_field(LATEST_TARGET_LEVEL) + + -- Get brand configuration for inversion handling + local brand_config = get_brand_config(device) + + -- Apply brand-specific inversion if needed (device reports inverted value) + local ui_reported_level = reported_level + if brand_config and brand_config.invert_level then + ui_reported_level = 100 - reported_level + end + + if latest_target_level ~= nil then + -- Active step control: check if device reached target position (compare UI values) + if utils.round(ui_reported_level) == utils.round(latest_target_level) then + -- Device reached target position, clear target marker and timeout timer + device:set_field(LATEST_TARGET_LEVEL, nil) + local timer = device:get_field(TARGET_LEVEL_TIME_OUT) + if timer ~= nil then + device.thread:cancel_timer(timer) + device:set_field(TARGET_LEVEL_TIME_OUT, nil) + end + end + end +end + +-- Handle device position report from Level cluster (for Feibit, Axis devices) +-- Level cluster reports 0-254, convert to percentage 0-100 +local function level_report_handler(driver, device, value, zb_rx) + local level_value = value.value or 0 + local reported_level = math.floor(level_value / 254.0 * 100) + shade_level_report_handler(driver, device, reported_level, zb_rx) +end + +-- Handle device position report from WindowCovering cluster +-- Reports 0-100 percentage directly +local function window_covering_report_handler(driver, device, value, zb_rx) + local reported_level = value.value or 0 + shade_level_report_handler(driver, device, reported_level, zb_rx) +end + +-- Handle device position report from AnalogOutput cluster (for Aqara devices) +-- Reports 0-100 percentage directly +local function analog_output_report_handler(driver, device, value, zb_rx) + local reported_level = value.value or 0 + shade_level_report_handler(driver, device, reported_level, zb_rx) +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] = window_covering_report_handler, + }, + [clusters.Level.ID] = { + [clusters.Level.attributes.CurrentLevel.ID] = level_report_handler, + }, + [clusters.AnalogOutput.ID] = { + [clusters.AnalogOutput.attributes.PresentValue.ID] = analog_output_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 From ae3fd80ced87d900791621646e29a8f07488e2ec Mon Sep 17 00:00:00 2001 From: zs Date: Thu, 9 Jul 2026 10:25:38 +0800 Subject: [PATCH 2/5] repair stateless_handler --- .../statelessWindowShadeLevelStep.yaml | 16 +++ ...ow-treatment-aqara-roller-shade-rotate.yml | 2 + .../profiles/window-treatment-battery.yml | 2 + .../profiles/window-treatment-no-preset.yml | 2 + .../profiles/window-treatment-powerSource.yml | 4 +- ...w-treatment-profile-no-firmware-update.yml | 4 +- .../profiles/window-treatment-profile.yml | 2 + .../profiles/window-treatment-reverse.yml | 2 + .../src/hanssem/init.lua | 13 +- .../src/stateless_handler/brand_configs.lua | 1 + .../src/stateless_handler/init.lua | 52 +++++-- ...bee_window_treatment_stateless_handler.lua | 133 ++++++++++++++++++ .../src/tuya_utils/init.lua | 0 13 files changed, 217 insertions(+), 16 deletions(-) create mode 100644 drivers/SmartThings/zigbee-window-treatment/capabilities/statelessWindowShadeLevelStep.yaml create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_stateless_handler.lua create mode 100644 drivers/SmartThings/zigbee-window-treatment/src/tuya_utils/init.lua 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-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/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 index 57b3fe7984..e9e31ccd5a 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/brand_configs.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/brand_configs.lua @@ -77,6 +77,7 @@ local BRAND_CONFIGS = { 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", diff --git a/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/init.lua index d4cc3fb036..ae2517cea2 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/init.lua @@ -5,6 +5,24 @@ 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" @@ -32,9 +50,9 @@ local function get_brand_config(device) if #config.models == 0 then return config end - -- Check if model matches any in the list + -- Check if model matches any in the list (case-insensitive substring match) for _, model_pattern in ipairs(config.models) do - if string.find(model, model_pattern, 1, true) then + if string.find(string.lower(model), string.lower(model_pattern), 1, true) then return config end end @@ -62,13 +80,8 @@ local function step_shade_level_handler(driver, device, command) 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 = current_level + step - - if ui_target_level > 100 then - ui_target_level = 100 - elseif ui_target_level < 0 then - ui_target_level = 0 - end + 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 @@ -95,9 +108,28 @@ local function step_shade_level_handler(driver, device, command) -- Send command based on brand configuration if brand_config and brand_config.use_level_cluster then - -- Feibit uses Level cluster + -- 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)) 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..cab638aec9 --- /dev/null +++ b/drivers/SmartThings/zigbee-window-treatment/src/test/test_zigbee_window_treatment_stateless_handler.lua @@ -0,0 +1,133 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +-- Direct unit test for stateless_handler module +-- Tests the handler functions directly without relying on can_handle mechanism + +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 + +zigbee_test_utils.prepare_zigbee_env_info() + +-- Mock device for aqara (invert_level = true) +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 } + } + } + } +) + +local function test_init() + test.mock_device.add_test_device(aqara_device) +end + +test.set_test_init_function(test_init) + +-- ============================================================================ +-- Test: stepShadeLevel command for aqara devices (invert_level = true) +-- ============================================================================ + +test.register_coroutine_test( + "stateless_handler: stepShadeLevel - aqara device increase", + function() + -- Set initial state by simulating a position report + -- Note: Both aqara sub-driver and stateless_handler will process this message + -- aqara sends windowShade event, stateless_handler sends windowShadeLevel event + 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 } } + }) + -- Also expect the windowShade event from aqara sub-driver + 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() + + test.socket.capability:__queue_receive({ + aqara_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { stepSize = 10 } } + }) + + -- Should send GoToLiftPercentage with inverted value (100 - 60 = 40) + test.socket.zigbee:__expect_send({ + aqara_device.id, + WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 40) + }) + end, + { min_api_version = 17 } +) + +test.run_registered_tests() +-- ============================================================================ + +test.register_coroutine_test( + "stateless_handler: stepShadeLevel - aqara device increase", + function() + 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 } + } + } + } + ) + test.mock_device.add_test_device(aqara_device) + + -- Set initial state by simulating a position report + -- Note: Both aqara sub-driver and stateless_handler will process this message + -- aqara sends windowShade event, stateless_handler sends windowShadeLevel event + 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 } } + }) + -- Also expect the windowShade event from aqara sub-driver + 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() + + test.socket.capability:__queue_receive({ + aqara_device.id, + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { stepSize = 10 } } + }) + + -- Should send GoToLiftPercentage with inverted value (100 - 60 = 40) + test.socket.zigbee:__expect_send({ + aqara_device.id, + WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 40) + }) + 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 From 82594e69590ad8543a4bb65a8be9ff2279e2d81b Mon Sep 17 00:00:00 2001 From: zs Date: Thu, 9 Jul 2026 14:51:33 +0800 Subject: [PATCH 3/5] __Passed 9 of 9 tests__ --- ...bee_window_treatment_stateless_handler.lua | 351 +++++++++++++++--- 1 file changed, 308 insertions(+), 43 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 cab638aec9..8df45cdc43 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 @@ -2,7 +2,7 @@ -- Licensed under the Apache License, Version 2.0 -- Direct unit test for stateless_handler module --- Tests the handler functions directly without relying on can_handle mechanism +-- Tests boundary conditions for stepShadeLevel command local capabilities = require "st.capabilities" local clusters = require "st.zigbee.zcl.clusters" @@ -13,9 +13,14 @@ local zigbee_test_utils = require "integration_test.zigbee_test_utils" local WindowCovering = clusters.WindowCovering local AnalogOutput = clusters.AnalogOutput -zigbee_test_utils.prepare_zigbee_env_info() +-- Register the statelessWindowShadeLevelStep capability for testing +test.add_package_capability("statelessWindowShadeLevelStep.yaml") --- Mock device for aqara (invert_level = true) +-- 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"), @@ -31,22 +36,192 @@ local aqara_device = test.mock_device.build_test_zigbee_device( } ) +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: stepShadeLevel command for aqara devices (invert_level = true) --- ============================================================================ +-- 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 - aqara device increase", + "stateless_handler: stepShadeLevel - nil stepSize does not send command", function() - -- Set initial state by simulating a position report - -- Note: Both aqara sub-driver and stateless_handler will process this message - -- aqara sends windowShade event, stateless_handler sends windowShadeLevel event + -- Reset Aqara device state test.socket.zigbee:__queue_receive({ aqara_device.id, WindowCovering.attributes.CurrentPositionLiftPercentage:build_test_attr_report(aqara_device, 50) @@ -55,52 +230,99 @@ test.register_coroutine_test( aqara_device.id, { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } }) - -- Also expect the windowShade event from aqara sub-driver 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 = { stepSize = 10 } } + { capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = {} } }) - - -- Should send GoToLiftPercentage with inverted value (100 - 60 = 40) + + -- 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, 40) + WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 49) }) end, { min_api_version = 17 } ) -test.run_registered_tests() --- ============================================================================ +-- 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 - aqara device increase", + "stateless_handler: stepShadeLevel - continuous step operations with mixed directions", function() - 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 } - } - } - } - ) - test.mock_device.add_test_device(aqara_device) - - -- Set initial state by simulating a position report - -- Note: Both aqara sub-driver and stateless_handler will process this message - -- aqara sends windowShade event, stateless_handler sends windowShadeLevel event + -- 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) @@ -109,23 +331,66 @@ test.register_coroutine_test( aqara_device.id, { capability_id = "windowShadeLevel", component_id = "main", attribute_id = "shadeLevel", state = { value = 50 } } }) - -- Also expect the windowShade event from aqara sub-driver 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 = { stepSize = 10 } } + { 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 } } }) - - -- Should send GoToLiftPercentage with inverted value (100 - 60 = 40) test.socket.zigbee:__expect_send({ aqara_device.id, WindowCovering.server.commands.GoToLiftPercentage(aqara_device, 40) }) + test.wait_for_events() end, { min_api_version = 17 } ) From 6f9a9cc1488c9ed8721863c1bbd5c1aec5f48961 Mon Sep 17 00:00:00 2001 From: zs Date: Thu, 9 Jul 2026 16:53:51 +0800 Subject: [PATCH 4/5] cancel timer --- .../src/stateless_handler/init.lua | 33 ------------------- 1 file changed, 33 deletions(-) diff --git a/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/init.lua index ae2517cea2..5296d34ce3 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/init.lua @@ -26,8 +26,6 @@ end -- Field keys for tracking target level and timeout timer local LATEST_TARGET_LEVEL = "__latest_target_level" -local TARGET_LEVEL_TIME_OUT = "__target_level_timeout" -local TARGET_LEVEL_TIME_OUT_SECONDS = 30 -- Get brand configuration for a device local function get_brand_config(device) @@ -93,19 +91,6 @@ local function step_shade_level_handler(driver, device, command) -- Set target_level for tracking (store UI value) device:set_field(LATEST_TARGET_LEVEL, ui_target_level) - -- Cancel previous timeout timer if exists - local old_timer = device:get_field(TARGET_LEVEL_TIME_OUT) - if old_timer ~= nil then - device.thread:cancel_timer(old_timer) - end - - -- Set timeout timer to ensure target_level is cleared after operation completes - local timer = device.thread:call_with_delay(TARGET_LEVEL_TIME_OUT_SECONDS, function(d) - device:set_field(LATEST_TARGET_LEVEL, nil) - device:set_field(TARGET_LEVEL_TIME_OUT, nil) - end) - device:set_field(TARGET_LEVEL_TIME_OUT, timer) - -- Send command based on brand configuration if brand_config and brand_config.use_level_cluster then -- Feibit/Axis uses Level cluster @@ -142,27 +127,9 @@ end -- zb_rx: The zigbee receive message for endpoint info local function shade_level_report_handler(driver, device, reported_level, zb_rx) local latest_target_level = device:get_field(LATEST_TARGET_LEVEL) - - -- Get brand configuration for inversion handling - local brand_config = get_brand_config(device) - - -- Apply brand-specific inversion if needed (device reports inverted value) - local ui_reported_level = reported_level - if brand_config and brand_config.invert_level then - ui_reported_level = 100 - reported_level - end - if latest_target_level ~= nil then - -- Active step control: check if device reached target position (compare UI values) - if utils.round(ui_reported_level) == utils.round(latest_target_level) then -- Device reached target position, clear target marker and timeout timer device:set_field(LATEST_TARGET_LEVEL, nil) - local timer = device:get_field(TARGET_LEVEL_TIME_OUT) - if timer ~= nil then - device.thread:cancel_timer(timer) - device:set_field(TARGET_LEVEL_TIME_OUT, nil) - end - end end end From d0533751b78a4183d6253d6869b1ceaa78cd951e Mon Sep 17 00:00:00 2001 From: zs Date: Thu, 9 Jul 2026 17:58:58 +0800 Subject: [PATCH 5/5] --report handler --- .../src/stateless_handler/init.lua | 33 +++---------------- 1 file changed, 4 insertions(+), 29 deletions(-) diff --git a/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/init.lua index 5296d34ce3..ae1cd19bbb 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/stateless_handler/init.lua @@ -121,11 +121,7 @@ local function step_shade_level_handler(driver, device, command) end end --- Handle device position report from WindowCovering cluster, Level cluster, or AnalogOutput cluster --- Parameters: --- reported_level: The raw level value from the device (0-100 percentage) --- zb_rx: The zigbee receive message for endpoint info -local function shade_level_report_handler(driver, device, reported_level, zb_rx) +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 @@ -133,27 +129,6 @@ local function shade_level_report_handler(driver, device, reported_level, zb_rx) end end --- Handle device position report from Level cluster (for Feibit, Axis devices) --- Level cluster reports 0-254, convert to percentage 0-100 -local function level_report_handler(driver, device, value, zb_rx) - local level_value = value.value or 0 - local reported_level = math.floor(level_value / 254.0 * 100) - shade_level_report_handler(driver, device, reported_level, zb_rx) -end - --- Handle device position report from WindowCovering cluster --- Reports 0-100 percentage directly -local function window_covering_report_handler(driver, device, value, zb_rx) - local reported_level = value.value or 0 - shade_level_report_handler(driver, device, reported_level, zb_rx) -end - --- Handle device position report from AnalogOutput cluster (for Aqara devices) --- Reports 0-100 percentage directly -local function analog_output_report_handler(driver, device, value, zb_rx) - local reported_level = value.value or 0 - shade_level_report_handler(driver, device, reported_level, zb_rx) -end local stateless_handler = { NAME = "Zigbee Window Treatment Stateless Step Handlers", @@ -165,13 +140,13 @@ local stateless_handler = { zigbee_handlers = { attr = { [clusters.WindowCovering.ID] = { - [clusters.WindowCovering.attributes.CurrentPositionLiftPercentage.ID] = window_covering_report_handler, + [clusters.WindowCovering.attributes.CurrentPositionLiftPercentage.ID] = shade_level_report_handler, }, [clusters.Level.ID] = { - [clusters.Level.attributes.CurrentLevel.ID] = level_report_handler, + [clusters.Level.attributes.CurrentLevel.ID] = shade_level_report_handler, }, [clusters.AnalogOutput.ID] = { - [clusters.AnalogOutput.attributes.PresentValue.ID] = analog_output_report_handler, + [clusters.AnalogOutput.attributes.PresentValue.ID] = shade_level_report_handler, }, }, },