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..ad2dc6f0ac 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: statelessWindowShadeLevelStep + 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..c541d47ae8 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: statelessWindowShadeLevelStep + 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..94e98998b3 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: statelessWindowShadeLevelStep + 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..8930fbc4cf 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: statelessWindowShadeLevelStep + 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..98218b5065 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: statelessWindowShadeLevelStep + 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..021aa36353 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: statelessWindowShadeLevelStep + 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..8248cde383 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: statelessWindowShadeLevelStep + 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..e90a3fdb23 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: statelessWindowShadeLevelStep + 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 66581d0ae8..092ba72c45 100644 --- a/drivers/SmartThings/matter-window-covering/src/init.lua +++ b/drivers/SmartThings/matter-window-covering/src/init.lua @@ -20,6 +20,11 @@ local battery_support = { local REVERSE_POLARITY = "__reverse_polarity" local PRESET_LEVEL_KEY = "__preset_level_key" local DEFAULT_PRESET_LEVEL = 50 +local LATEST_TARGET_LEVEL = "_latest_target_level" +local TARGET_LEVEL_TIME_OUT = "_target_level_timeout" +local TARGET_LEVEL_TIME_OUT_SECONDS = 30 + +local TARGET_REACH_TOLERANCE = 1 local function find_default_endpoint(device, cluster) local res = device.MATTER_DEFAULT_ENDPOINT @@ -121,6 +126,7 @@ local function device_removed(driver, device) log.info("device removed") end -- capability handlers local function handle_preset(driver, device, cmd) + device:set_field(LATEST_TARGET_LEVEL, nil) local lift_value = device:get_latest_state( "main", capabilities.windowShadePreset.ID, capabilities.windowShadePreset.position.NAME ) or DEFAULT_PRESET_LEVEL @@ -139,6 +145,7 @@ end -- close covering local function handle_close(driver, device, cmd) + device:set_field(LATEST_TARGET_LEVEL, nil) local endpoint_id = device:component_to_endpoint(cmd.component) local req = clusters.WindowCovering.server.commands.DownOrClose(device, endpoint_id) if device:get_field(REVERSE_POLARITY) then @@ -149,6 +156,7 @@ end -- open covering local function handle_open(driver, device, cmd) + device:set_field(LATEST_TARGET_LEVEL, nil) local endpoint_id = device:component_to_endpoint(cmd.component) local req = clusters.WindowCovering.server.commands.UpOrOpen(device, endpoint_id) if device:get_field(REVERSE_POLARITY) then @@ -159,6 +167,7 @@ end -- pause covering local function handle_pause(driver, device, cmd) + device:set_field(LATEST_TARGET_LEVEL, nil) local endpoint_id = device:component_to_endpoint(cmd.component) local req = clusters.WindowCovering.server.commands.StopMotion(device, endpoint_id) device:send(req) @@ -166,6 +175,7 @@ end -- move to shade level between 0-100 local function handle_shade_level(driver, device, cmd) + device:set_field(LATEST_TARGET_LEVEL, nil) local endpoint_id = device:component_to_endpoint(cmd.component) local lift_percentage_value = 100 - cmd.args.shadeLevel local hundredths_lift_percentage = lift_percentage_value * 100 @@ -175,6 +185,51 @@ local function handle_shade_level(driver, device, cmd) device:send(req) end +-- window shade step control handler +local function window_shade_step_level_cmd(driver, device, cmd) + local step = cmd.args.stepSize + + -- Priority: use target_level if exists, otherwise use latest state + local target_level_field = device:get_field(LATEST_TARGET_LEVEL) + local current_level = target_level_field 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(LATEST_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(LATEST_TARGET_LEVEL, nil) + device:set_field(TARGET_LEVEL_TIME_OUT, nil) + end) + device:set_field(TARGET_LEVEL_TIME_OUT, timer) + + -- 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) @@ -194,6 +249,22 @@ local current_pos_handler = function(attribute) end local windowShade = capabilities.windowShade.windowShade local position = 100 - math.floor(ib.data.value / 100) + + -- Step control logic + local target_level_field = device:get_field(LATEST_TARGET_LEVEL) + if target_level_field and attribute == capabilities.windowShadeLevel.shadeLevel then + -- Allow ±1 degree tolerance for reaching target + if math.abs(position - target_level_field) <= TARGET_REACH_TOLERANCE 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 + local reverse = device:get_field(REVERSE_POLARITY) device:emit_event_for_endpoint(ib.endpoint_id, attribute(position)) @@ -253,6 +324,17 @@ local function current_status_handler(driver, device, ib, response) device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.closing() or windowShade.opening()) elseif state == 2 then -- closing device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.opening() or windowShade.closing()) + elseif state == 0 then -- stopped (idle) + -- Clear target_level when device stops moving + local target_level_field = device:get_field(LATEST_TARGET_LEVEL) + if target_level_field then + 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 elseif state ~= 0 then -- unknown device:emit_event_for_endpoint(ib.endpoint_id, windowShade.unknown()) end @@ -354,6 +436,9 @@ local matter_driver_template = { [capabilities.windowShadeLevel.ID] = { [capabilities.windowShadeLevel.commands.setShadeLevel.NAME] = handle_shade_level, }, + [capabilities.statelessWindowShadeLevelStep.ID] = { + [capabilities.statelessWindowShadeLevelStep.commands.stepShadeLevel.NAME] = window_shade_step_level_cmd + }, [capabilities.windowShadeTiltLevel.ID] = { [capabilities.windowShadeTiltLevel.commands.setShadeTiltLevel.NAME] = handle_shade_tilt_level, }, @@ -373,4 +458,4 @@ local matter_driver_template = { } local matter_driver = MatterDriver("matter-window-covering", matter_driver_template) -matter_driver:run() +matter_driver:run() \ No newline at end of file diff --git a/drivers/SmartThings/matter-window-covering/src/test/test_matter_window_covering.lua b/drivers/SmartThings/matter-window-covering/src/test/test_matter_window_covering.lua index 9f273037f3..7c1a2d8c1c 100644 --- a/drivers/SmartThings/matter-window-covering/src/test/test_matter_window_covering.lua +++ b/drivers/SmartThings/matter-window-covering/src/test/test_matter_window_covering.lua @@ -1223,3 +1223,303 @@ test.register_coroutine_test( ) test.run_registered_tests() +-- stepShadeLevel tests + +test.register_coroutine_test( + "WindowShade stepShadeLevel cmd handler - step up", function() + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }}, + } + ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 9000)} + ) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "WindowShade stepShadeLevel cmd handler - step down", function() + -- First set initial position to 50 + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 5000 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(50) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + test.wait_for_events() + + -- Step down by 20 + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -20 }}, + } + ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 7000)} + ) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "WindowShade stepShadeLevel cmd handler - continuous step with target tracking", function() + -- First set initial position to 30 + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 7000 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(30) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + test.wait_for_events() + + -- First step up by 10 (target = 40) + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }}, + } + ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 6000)} + ) + test.wait_for_events() + + -- Second step up by 10 without waiting for report (target = 50, using previous target 40 as base) + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }}, + } + ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 5000)} + ) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "WindowShade stepShadeLevel - target reached clears target marker", function() + -- Set initial position to 50 + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 5000 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(50) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + test.wait_for_events() + + -- Step up by 10 (target = 60) + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }}, + } + ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 4000)} + ) + test.wait_for_events() + + -- Device reports position 60 (within tolerance) + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 4000 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(60) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + -- Target marker should be cleared internally (no external verification needed) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "WindowShade stepShadeLevel - step up to maximum (100)", function() + -- Set initial position to 95 + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 500 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(95) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + test.wait_for_events() + + -- Step up by 10 (should clamp to 100) + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }}, + } + ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 0)} + ) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "WindowShade stepShadeLevel - step down to minimum (0)", function() + -- Set initial position to 5 + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 9500 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(5) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + test.wait_for_events() + + -- Step down by 10 (should clamp to 0) + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { -10 }}, + } + ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 10000)} + ) + end, + { + min_api_version = 19 + } +) + +test.register_coroutine_test( + "WindowShade stepShadeLevel - other commands clear target marker", function() + -- Set initial position to 50 + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 5000 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(50) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + test.wait_for_events() + + -- Step up by 10 + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "statelessWindowShadeLevelStep", component = "main", command = "stepShadeLevel", args = { 10 }}, + } + ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 4000)} + ) + test.wait_for_events() + + -- setShadeLevel should clear target marker + test.socket.capability:__queue_receive( + { + mock_device.id, + {capability = "windowShadeLevel", component = "main", command = "setShadeLevel", args = { 25 }}, + } + ) + test.socket.matter:__expect_send( + {mock_device.id, WindowCovering.server.commands.GoToLiftPercentage(mock_device, 10, 7500)} + ) + end, + { + min_api_version = 19 + } +) + +test.run_registered_tests()