diff --git a/drivers/SmartThings/matter-window-covering/profiles/window-covering-battery.yml b/drivers/SmartThings/matter-window-covering/profiles/window-covering-battery.yml index a1896ca634..a088e87e76 100644 --- a/drivers/SmartThings/matter-window-covering/profiles/window-covering-battery.yml +++ b/drivers/SmartThings/matter-window-covering/profiles/window-covering-battery.yml @@ -8,6 +8,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: battery version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-window-covering/profiles/window-covering-batteryLevel.yml b/drivers/SmartThings/matter-window-covering/profiles/window-covering-batteryLevel.yml index fa46d87cc6..200be29321 100644 --- a/drivers/SmartThings/matter-window-covering/profiles/window-covering-batteryLevel.yml +++ b/drivers/SmartThings/matter-window-covering/profiles/window-covering-batteryLevel.yml @@ -8,6 +8,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: batteryLevel version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-window-covering/profiles/window-covering-profile.yml b/drivers/SmartThings/matter-window-covering/profiles/window-covering-profile.yml index 903565e68c..4258be4360 100644 --- a/drivers/SmartThings/matter-window-covering/profiles/window-covering-profile.yml +++ b/drivers/SmartThings/matter-window-covering/profiles/window-covering-profile.yml @@ -9,6 +9,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: battery version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-battery.yml b/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-battery.yml index 27f7f1f61a..b3f1ac25d6 100644 --- a/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-battery.yml +++ b/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-battery.yml @@ -10,6 +10,8 @@ components: version: 1 - id: windowShadeTiltLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: battery version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-only-battery.yml b/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-only-battery.yml index ce05fc82bc..c3a79ffada 100644 --- a/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-only-battery.yml +++ b/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-only-battery.yml @@ -6,6 +6,8 @@ components: version: 1 - id: windowShadeTiltLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: battery version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-only.yml b/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-only.yml index 1ae1e4a7a7..5e198e4c33 100644 --- a/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-only.yml +++ b/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt-only.yml @@ -6,6 +6,8 @@ components: version: 1 - id: windowShadeTiltLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt.yml b/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt.yml index c6a759b610..974e835bee 100644 --- a/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt.yml +++ b/drivers/SmartThings/matter-window-covering/profiles/window-covering-tilt.yml @@ -10,6 +10,8 @@ components: version: 1 - id: windowShadeTiltLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/matter-window-covering/profiles/window-covering.yml b/drivers/SmartThings/matter-window-covering/profiles/window-covering.yml index fbd40ed08d..12d1d3b4da 100644 --- a/drivers/SmartThings/matter-window-covering/profiles/window-covering.yml +++ b/drivers/SmartThings/matter-window-covering/profiles/window-covering.yml @@ -8,6 +8,8 @@ components: version: 1 - id: windowShadeLevel version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh diff --git a/drivers/SmartThings/matter-window-covering/src/init.lua b/drivers/SmartThings/matter-window-covering/src/init.lua index 6759560c50..127ff46a64 100644 --- a/drivers/SmartThings/matter-window-covering/src/init.lua +++ b/drivers/SmartThings/matter-window-covering/src/init.lua @@ -31,6 +31,8 @@ local REVERSE_POLARITY = "__reverse_polarity" local PRESET_LEVEL_KEY = "__preset_level_key" local DEFAULT_PRESET_LEVEL = 50 +local TARGET_REACH_TOLERANCE = 1 + local function find_default_endpoint(device, cluster) local res = device.MATTER_DEFAULT_ENDPOINT local eps = device:get_endpoints(cluster) @@ -185,6 +187,38 @@ local function handle_shade_level(driver, device, cmd) device:send(req) end +-- knob control handler +local function knob_to_window_shade_step_cmd(driver, device, cmd) + local step = cmd.args.stepSize + + -- Priority: use knob_target_level if exists + local knob_target = device:get_field("knob_target_level") + local current_level = knob_target or + device:get_latest_state("main", capabilities.windowShadeLevel.ID, + capabilities.windowShadeLevel.shadeLevel.NAME) or 0 + + -- Calculate new target (user level: 0-100, 0=closed, 100=open) + local target_level = current_level + step + if target_level > 100 then target_level = 100 + elseif target_level < 0 then target_level = 0 + end + + -- Update tracking state + device:set_field("knob_target_level", target_level) + + -- Matter uses inverted logic (like IKEA) + -- User level: 0=closed, 100=open + -- Matter level: 10000=open, 0=closed (in percent100ths) + local lift_percentage_value = 100 - target_level + local hundredths_lift_percentage = lift_percentage_value * 100 + + local endpoint_id = device:component_to_endpoint(cmd.component) + local req = clusters.WindowCovering.server.commands.GoToLiftPercentage( + device, endpoint_id, hundredths_lift_percentage + ) + device:send(req) +end + -- move to shade tilt level between 0-100 local function handle_shade_tilt_level(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) @@ -204,6 +238,17 @@ local current_pos_handler = function(attribute) end local windowShade = capabilities.windowShade.windowShade local position = 100 - math.floor(ib.data.value / 100) + + -- Knob control logic + local knob_target = device:get_field("knob_target_level") + + if knob_target and attribute == capabilities.windowShadeLevel.shadeLevel then + -- Allow ±1 degree tolerance for reaching target + if math.abs(position - knob_target) <= TARGET_REACH_TOLERANCE then + device:set_field("knob_target_level", nil) + end + end + local reverse = device:get_field(REVERSE_POLARITY) device:emit_event_for_endpoint(ib.endpoint_id, attribute(position)) @@ -264,6 +309,7 @@ local function current_status_handler(driver, device, ib, response) elseif state == 2 then -- closing device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.opening() or windowShade.closing()) elseif state ~= 0 then -- unknown + device:set_field("knob_target_level", nil) -- clean this field once the window covering stop device:emit_event_for_endpoint(ib.endpoint_id, windowShade.unknown()) end end @@ -364,6 +410,9 @@ local matter_driver_template = { [capabilities.windowShadeLevel.ID] = { [capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = handle_shade_level, }, + [capabilities.statelessSwitchLevelStep.ID] = { + [capabilities.statelessSwitchLevelStep.commands.stepLevel.NAME] = knob_to_window_shade_step_cmd + }, [capabilities.windowShadeTiltLevel.ID] = { [capabilities.windowShadeTiltLevel.commands.setShadeTiltLevel.NAME] = handle_shade_tilt_level, }, 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..491333e9c9 100644 --- a/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara.yml +++ b/drivers/SmartThings/zigbee-window-treatment/profiles/window-treatment-aqara.yml @@ -8,6 +8,8 @@ components: version: 1 - id: stse.deviceInitialization version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: firmwareUpdate version: 1 - id: refresh 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..8a04f2afbd 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: statelessSwitchLevelStep + version: 1 - id: battery version: 1 - id: firmwareUpdate diff --git a/drivers/SmartThings/zigbee-window-treatment/src/aqara/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/aqara/init.lua index 1b66236038..5053bb7044 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/aqara/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/aqara/init.lua @@ -8,6 +8,7 @@ local cluster_base = require "st.zigbee.cluster_base" local data_types = require "st.zigbee.data_types" local aqara_utils = require "aqara/aqara_utils" local window_treatment_utils = require "window_treatment_utils" +local utils = require "st.utils" local Basic = clusters.Basic local WindowCovering = clusters.WindowCovering @@ -24,6 +25,8 @@ local INIT_STATE_INIT = "init" local INIT_STATE_OPEN = "open" local INIT_STATE_CLOSE = "close" local INIT_STATE_REVERSE = "reverse" +local TARGET_LEVEL_TIME_OUT = "_target_level_timeout" +local TARGET_LEVEL_TIME_OUT_SECONDS = 30 local PREF_INITIALIZE = "\x00\x01\x00\x00\x00\x00\x00" local PREF_SOFT_TOUCH_OFF = "\x00\x08\x00\x00\x00\x01\x00" @@ -37,6 +40,44 @@ local function window_shade_level_cmd(driver, device, command) aqara_utils.shade_level_cmd(driver, device, command) end +local function window_shade_step_level_cmd(driver, device, command) + local step = command.args.stepSize + + -- Priority: use target_level if exists, otherwise use latest state + local target_level_field = device:get_field("target_level") + local current_level = target_level_field or + device:get_latest_state("main", capabilities.windowShadeLevel.ID, capabilities.windowShadeLevel.shadeLevel.NAME) or 0 + + local target_level = current_level + step + if target_level > 100 then + target_level = 100 + elseif target_level < 0 then + target_level = 0 + end + target_level = utils.round(target_level) + + -- Set target_level for tracking + device:set_field("target_level", 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 30 second timeout timer to ensure target_level is cleared + local timer = device.thread:call_with_delay(TARGET_LEVEL_TIME_OUT_SECONDS, function(d) + device:set_field("target_level", nil) + device:set_field(TARGET_LEVEL_TIME_OUT, nil) + end) + device:set_field(TARGET_LEVEL_TIME_OUT, timer) + + -- Don't emit to cloud, let device reports drive UI + -- device:emit_event(capabilities.windowShadeLevel.shadeLevel(target_level)) + + device:send_to_component(command.component, WindowCovering.server.commands.GoToLiftPercentage(device, target_level)) +end + local function set_initialized_state_handler(driver, device, command) -- update ui device:emit_event(deviceInitialization.initializedState.initializing()) @@ -61,12 +102,45 @@ local function set_initialized_state_handler(driver, device, command) end local function shade_level_report_legacy_handler(driver, device, value, zb_rx) + local reported_level = value.value + local target_level_field = device:get_field("target_level") + + if target_level_field then + -- Active step control + if utils.round(reported_level) == utils.round(target_level_field) then + -- Device reached target position, clear target marker and timeout timer + device:set_field("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 + -- Always emit to update UI with actual device position + end + -- for version 34 aqara_utils.emit_shade_level_event(device, value) aqara_utils.emit_shade_event(device, value) end local function shade_level_report_handler(driver, device, value, zb_rx) + local reported_level = value.value + local target_level_field = device:get_field("target_level") + + if target_level_field then + -- Active step control + if utils.round(reported_level) == utils.round(target_level_field) then + -- Device reached target position, clear target marker and timeout timer + device:set_field("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 + -- Always emit to update UI with actual device position + end aqara_utils.emit_shade_level_event(device, value) aqara_utils.emit_shade_event(device, value) end @@ -190,6 +264,9 @@ local aqara_window_treatment_handler = { }, [capabilities.refresh.ID] = { [capabilities.refresh.commands.refresh.NAME] = do_refresh + }, + [capabilities.statelessSwitchLevelStep.ID] = { + [capabilities.statelessSwitchLevelStep.commands.stepLevel.NAME] = window_shade_step_level_cmd } }, zigbee_handlers = { diff --git a/drivers/SmartThings/zigbee-window-treatment/src/invert-lift-percentage/init.lua b/drivers/SmartThings/zigbee-window-treatment/src/invert-lift-percentage/init.lua index b586459b9a..b4f48130a5 100644 --- a/drivers/SmartThings/zigbee-window-treatment/src/invert-lift-percentage/init.lua +++ b/drivers/SmartThings/zigbee-window-treatment/src/invert-lift-percentage/init.lua @@ -5,13 +5,26 @@ local capabilities = require "st.capabilities" local zcl_clusters = require "st.zigbee.zcl.clusters" local window_shade_utils = require "window_shade_utils" +local utils = require "st.utils" +local log = require "log" local WindowCovering = zcl_clusters.WindowCovering local SHADE_SET_STATUS = "shade_set_status" +local TARGET_REACH_TOLERANCE = 1 -- ±1 degree tolerance for reaching target local function current_position_attr_handler(driver, device, value, zb_rx) local level = 100 - value.value + + local last_target_level = device:get_field("last_target_level") + log.info("---------->IKEA curtain report level:", level, "last_target_level:", last_target_level) + if last_target_level then + if math.abs(level - last_target_level) <= TARGET_REACH_TOLERANCE then + device:set_field("last_target_level", nil) + log.info("----------->IKEA curtain reached target, clearing last_target_level") + end + end + local current_level = device:get_latest_state("main", capabilities.windowShadeLevel.ID, capabilities.windowShadeLevel.shadeLevel.NAME) local windowShade = capabilities.windowShade.windowShade if level == -155 then -- unknown position @@ -57,6 +70,7 @@ local function current_position_attr_handler(driver, device, value, zb_rx) end local function set_shade_level(device, value, command) + device:set_field("last_target_level", nil) local level = 100 - value device:send_to_component(command.component, WindowCovering.server.commands.GoToLiftPercentage(device, level)) end @@ -70,6 +84,39 @@ local function window_shade_preset_cmd(driver, device, command) set_shade_level(device, level, command) end +local function window_shade_step_level_cmd(driver, device, command) + local step = command.args.stepSize + log.info("------------->IKEA step size:", step) + + -- Priority: use last_target_level if exists + local last_target_level = device:get_field("last_target_level") + local current_level = last_target_level or + device:get_latest_state("main", capabilities.windowShadeLevel.ID, + capabilities.windowShadeLevel.shadeLevel.NAME) or 0 + + log.info("------------->IKEA current_level:", current_level, "from last_target_level:", last_target_level ~= nil) + + -- Calculate new target (user level: 0-100, 0=closed, 100=open) + local target_level = current_level + step + if target_level > 100 then target_level = 100 + elseif target_level < 0 then target_level = 0 + end + target_level = utils.round(target_level) + + log.info("------------->IKEA target_level:", target_level) + + -- Update tracking state + device:set_field("last_target_level", target_level) + + -- Invert for IKEA: user level → device level + local device_level = 100 - target_level + + log.info("------------->IKEA sending device_level:", device_level) + + device:send_to_component(command.component, + WindowCovering.server.commands.GoToLiftPercentage(device, device_level)) +end + local ikea_window_treatment = { NAME = "inverted lift percentage", zigbee_handlers = { @@ -85,6 +132,9 @@ local ikea_window_treatment = { }, [capabilities.windowShadePreset.ID] = { [capabilities.windowShadePreset.commands.presetPosition.NAME] = window_shade_preset_cmd + }, + [capabilities.statelessSwitchLevelStep.ID] = { + [capabilities.statelessSwitchLevelStep.commands.stepLevel.NAME] = window_shade_step_level_cmd } }, can_handle = require("invert-lift-percentage.can_handle"), diff --git a/drivers/Unofficial/tuya-zigbee/profiles/window-treatment-reverse.yml b/drivers/Unofficial/tuya-zigbee/profiles/window-treatment-reverse.yml index 9712eb6849..a6bbc35a10 100644 --- a/drivers/Unofficial/tuya-zigbee/profiles/window-treatment-reverse.yml +++ b/drivers/Unofficial/tuya-zigbee/profiles/window-treatment-reverse.yml @@ -4,6 +4,8 @@ components: capabilities: - id: windowShade version: 1 + - id: statelessSwitchLevelStep + version: 1 - id: windowShadeLevel version: 1 - id: windowShadePreset diff --git a/drivers/Unofficial/tuya-zigbee/src/curtain/init.lua b/drivers/Unofficial/tuya-zigbee/src/curtain/init.lua index 03c34cac65..1667c0d998 100644 --- a/drivers/Unofficial/tuya-zigbee/src/curtain/init.lua +++ b/drivers/Unofficial/tuya-zigbee/src/curtain/init.lua @@ -19,6 +19,7 @@ local device_management = require "st.zigbee.device_management" local tuya_utils = require "tuya_utils" local Basic = clusters.Basic local packet_id = 0 +local log = require "log" local PRESET_LEVEL = 50 local PRESET_LEVEL_KEY = "_presetLevel" @@ -109,6 +110,7 @@ local function window_shade_level(driver, device, command) if level > 100 then level = 100 end + log.info("capability handler level ------------->", level) level = utils.round(level) if device:get_manufacturer() == "_TZE284_nladmfvf" then level = 100 - level -- specific for _TZE284_nladmfvf @@ -137,7 +139,19 @@ local function tuya_cluster_handler(driver, device, zb_rx) -- dp means data point in tuya payload format local dp = raw:byte(3) local dp_data = raw:byte(10) - if dp == 0x03 then + if dp == 0x03 then + local knob_target = device:get_field("knob_target_level") + log.info("---------->tuya curtain report dp_data:", dp_data) + log.info("---------->knob_target:", knob_target) + if knob_target then + -- Allow ±1 degree tolerance for reaching target + if math.abs(knob_target - dp_data) <= 1 then + device:set_field("knob_target_level", nil) + log.info("tuya curtain reached target, clearing knob_target") + end + end + + -- Emit events with device-reported level (dp_data) window_shade_level_event = capabilities.windowShadeLevel.shadeLevel(dp_data) if dp_data == 0 then window_shade_val_event = capabilities.windowShade.windowShade("open") @@ -153,6 +167,34 @@ local function tuya_cluster_handler(driver, device, zb_rx) end end +local function knob_to_window_shade_step_cmd(driver, device, command) + local step = command.args.stepSize + log.info("------------->knob step size:", step) + + -- Priority: use knob_target_level if exists + local knob_target = device:get_field("knob_target_level") + local current_level = knob_target or + device:get_latest_state("main", capabilities.windowShadeLevel.ID, + capabilities.windowShadeLevel.shadeLevel.NAME) or 0 + + -- Calculate new target (user level: 0-100, 0=closed, 100=open) + log.info("------------->current_level:", current_level) + local target_level = current_level + step + if target_level > 100 then target_level = 100 + elseif target_level < 0 then target_level = 0 + end + log.info("------------->target_level:", target_level) + + -- Update tracking state + device:set_field("knob_target_level", target_level) + + target_level = utils.round(100 - target_level) + log.info("------------->sending level:", target_level) + + tuya_utils.send_tuya_command(device, '\x02', tuya_utils.DP_TYPE_VALUE, '\x00\x00'..string.pack(">I2", target_level), packet_id) + packet_id = increase_packet_id(packet_id) +end + local tuya_curtain_driver = { NAME = "tuya curtain", lifecycle_handlers = { @@ -173,6 +215,9 @@ local tuya_curtain_driver = { [capabilities.windowShadePreset.ID] = { [capabilities.windowShadePreset.commands.presetPosition.NAME] = window_shade_preset, [capabilities.windowShadePreset.commands.setPresetPosition.NAME] = set_preset_position_cmd + }, + [capabilities.statelessSwitchLevelStep.ID] = { + [capabilities.statelessSwitchLevelStep.commands.stepLevel.NAME] = knob_to_window_shade_step_cmd } }, zigbee_handlers = {