From 931af02afa6b2aba25b3b9ce0f3c7fe82601023f Mon Sep 17 00:00:00 2001 From: Tom Manley Date: Wed, 24 Jun 2026 13:28:27 -0500 Subject: [PATCH] Matter Window Covering: Fix shades that get stuck opening/closing As reported in https://community.smartthings.com/t/smartwings-shades-matter-over-thread-stuck-on-opening-and-closing/309925 and thanks to the analysis done by @ldeora, it seems some shades get stuck in an opening or closing state. This attempts to fix this by starting a timer when the shade reports that it stopped moving. If a lift or tilt report is received then it cancels the timer. If the timer fires (meaning a lift or tilt report was not received within the timeout) then it will do a read of lift and/or tilt to determine the current position and update the windowShade state. Resolves: https://github.com/SmartThingsCommunity/SmartThingsEdgeDrivers/issues/3043 --- .../matter-window-covering/src/init.lua | 63 +++++- .../src/test/test_matter_window_covering.lua | 203 ++++++++++++++++++ 2 files changed, 265 insertions(+), 1 deletion(-) diff --git a/drivers/SmartThings/matter-window-covering/src/init.lua b/drivers/SmartThings/matter-window-covering/src/init.lua index 66581d0ae8..5b3e9b7c15 100644 --- a/drivers/SmartThings/matter-window-covering/src/init.lua +++ b/drivers/SmartThings/matter-window-covering/src/init.lua @@ -20,6 +20,8 @@ local battery_support = { local REVERSE_POLARITY = "__reverse_polarity" local PRESET_LEVEL_KEY = "__preset_level_key" local DEFAULT_PRESET_LEVEL = 50 +local POSITION_READ_TIMER = "__position_read_timer" +local POSITION_READ_DELAY = 2 -- seconds to wait before reading position after stop local function find_default_endpoint(device, cluster) local res = device.MATTER_DEFAULT_ENDPOINT @@ -192,6 +194,14 @@ local current_pos_handler = function(attribute) if ib.data.value == nil then return end + + -- A position report arrived, so cancel any pending position-read timer + local pending_timer = device:get_field(POSITION_READ_TIMER) + if pending_timer ~= nil then + device.thread:cancel_timer(pending_timer) + device:set_field(POSITION_READ_TIMER, nil) + end + local windowShade = capabilities.windowShade.windowShade local position = 100 - math.floor(ib.data.value / 100) local reverse = device:get_field(REVERSE_POLARITY) @@ -244,6 +254,55 @@ local current_pos_handler = function(attribute) end end +local function endpoint_supports_window_covering_feature(device, endpoint_id, feature) + local eps = device:get_endpoints(clusters.WindowCovering.ID, {feature_bitmap = feature}) or {} + for _, ep in ipairs(eps) do + if ep == endpoint_id then return true end + end + return false +end + +-- Read current position attributes for the given endpoint, based on supported features. +local function read_current_window_covering_position(device, endpoint_id) + local read_req = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) + local has_read = false + + if endpoint_supports_window_covering_feature( + device, endpoint_id, clusters.WindowCovering.types.Feature.LIFT) then + read_req:merge( + clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths:read(device, endpoint_id) + ) + has_read = true + end + + if endpoint_supports_window_covering_feature( + device, endpoint_id, clusters.WindowCovering.types.Feature.TILT) then + read_req:merge( + clusters.WindowCovering.attributes.CurrentPositionTiltPercent100ths:read(device, endpoint_id) + ) + has_read = true + end + + if has_read then device:send(read_req) end +end + +-- Schedule a position read after POSITION_READ_DELAY seconds. If a spontaneous +-- position report arrives before the timer fires, the timer is cancelled in +-- current_pos_handler and no read is sent. If the timer fires, it means no +-- position report arrived and the device may be stuck in opening/closing state, +-- so we read the current position to resolve the final open/closed/partially_open state. +local function schedule_position_read(device, endpoint_id) + local pending_timer = device:get_field(POSITION_READ_TIMER) + if pending_timer ~= nil then + device.thread:cancel_timer(pending_timer) + end + local timer = device.thread:call_with_delay(POSITION_READ_DELAY, function() + device:set_field(POSITION_READ_TIMER, nil) + read_current_window_covering_position(device, endpoint_id) + end) + device:set_field(POSITION_READ_TIMER, timer) +end + -- checks the current position of the shade local function current_status_handler(driver, device, ib, response) local windowShade = capabilities.windowShade.windowShade @@ -253,7 +312,9 @@ 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 -- unknown + elseif state == 0 then -- not moving; schedule a read to resolve final state if no position report arrives + schedule_position_read(device, ib.endpoint_id) + else -- unknown device:emit_event_for_endpoint(ib.endpoint_id, windowShade.unknown()) end end 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..7c053b7901 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 @@ -7,11 +7,21 @@ local capabilities = require "st.capabilities" local t_utils = require "integration_test.utils" local uint32 = require "st.matter.data_types.Uint32" local clusters = require "st.matter.clusters" +local im = require "st.matter.interaction_model" local WindowCovering = clusters.WindowCovering test.disable_startup_messages() +-- Build the expected read request for both lift and tilt position attributes. +-- This is sent by current_status_handler whenever OperationalStatus == 0. +local function build_position_read_request(device, endpoint_id) + local req = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) + req:merge(WindowCovering.attributes.CurrentPositionLiftPercent100ths:read(device, endpoint_id)) + req:merge(WindowCovering.attributes.CurrentPositionTiltPercent100ths:read(device, endpoint_id)) + return req +end + local mock_device = test.mock_device.build_test_matter_device( { profile = t_utils.get_profile_definition("window-covering-tilt-battery.yml"), @@ -171,12 +181,19 @@ test.register_coroutine_test( "main", capabilities.windowShade.windowShade.closed() ) ) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + -- position report arrived before status; no timer to cancel when status fires; + -- timer fires after 2s and re-reads position (redundant but harmless for well-behaved devices) + test.socket.matter:__expect_send( + {mock_device.id, build_position_read_request(mock_device, 10)} + ) + test.mock_time.advance_time(2) end, { min_api_version = 17 @@ -204,12 +221,19 @@ test.register_coroutine_test( "main", capabilities.windowShade.windowShade.closed() ) ) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + -- position report arrived before status; no timer to cancel when status fires; + -- timer fires after 2s and re-reads position (redundant but harmless for well-behaved devices) + test.socket.matter:__expect_send( + {mock_device.id, build_position_read_request(mock_device, 10)} + ) + test.mock_time.advance_time(2) end, { min_api_version = 17 @@ -219,12 +243,19 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus state closed before lift position 0", function() test.socket.capability:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + -- timer fires after 2s with no position report to cancel it, so driver reads position + test.socket.matter:__expect_send( + {mock_device.id, build_position_read_request(mock_device, 10)} + ) + test.mock_time.advance_time(2) + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -252,12 +283,19 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus state closed before tilt position 0", function() test.socket.capability:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + -- timer fires after 2s with no position report to cancel it, so driver reads position + test.socket.matter:__expect_send( + {mock_device.id, build_position_read_request(mock_device, 10)} + ) + test.mock_time.advance_time(2) + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -303,12 +341,19 @@ test.register_coroutine_test( "main", capabilities.windowShade.windowShade.open() ) ) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + -- position report arrived before status; no timer to cancel when status fires; + -- timer fires after 2s and re-reads position (redundant but harmless for well-behaved devices) + test.socket.matter:__expect_send( + {mock_device.id, build_position_read_request(mock_device, 10)} + ) + test.mock_time.advance_time(2) end, { min_api_version = 17 @@ -336,12 +381,19 @@ test.register_coroutine_test( "main", capabilities.windowShade.windowShade.open() ) ) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + -- position report arrived before status; no timer to cancel when status fires; + -- timer fires after 2s and re-reads position (redundant but harmless for well-behaved devices) + test.socket.matter:__expect_send( + {mock_device.id, build_position_read_request(mock_device, 10)} + ) + test.mock_time.advance_time(2) end, { min_api_version = 17 @@ -351,12 +403,19 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus state open before lift position event", function() test.socket.capability:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + -- timer fires after 2s with no position report to cancel it, so driver reads position + test.socket.matter:__expect_send( + {mock_device.id, build_position_read_request(mock_device, 10)} + ) + test.mock_time.advance_time(2) + test.wait_for_events() test.socket.capability:__expect_send( mock_device:generate_test_message( "main", capabilities.windowShadeLevel.shadeLevel(100) @@ -384,12 +443,19 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus state open before tilt position event", function() test.socket.capability:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + -- timer fires after 2s with no position report to cancel it, so driver reads position + test.socket.matter:__expect_send( + {mock_device.id, build_position_read_request(mock_device, 10)} + ) + test.mock_time.advance_time(2) + test.wait_for_events() test.socket.capability:__expect_send( mock_device:generate_test_message( "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(100) @@ -435,12 +501,19 @@ test.register_coroutine_test( "main", capabilities.windowShade.windowShade.partially_open() ) ) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + -- position report arrived before status; no timer to cancel when status fires; + -- timer fires after 2s and re-reads position (redundant but harmless for well-behaved devices) + test.socket.matter:__expect_send( + {mock_device.id, build_position_read_request(mock_device, 10)} + ) + test.mock_time.advance_time(2) end, { min_api_version = 17 @@ -468,12 +541,19 @@ test.register_coroutine_test( "main", capabilities.windowShade.windowShade.partially_open() ) ) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + -- position report arrived before status; no timer to cancel when status fires; + -- timer fires after 2s and re-reads position (redundant but harmless for well-behaved devices) + test.socket.matter:__expect_send( + {mock_device.id, build_position_read_request(mock_device, 10)} + ) + test.mock_time.advance_time(2) end, { min_api_version = 17 @@ -483,12 +563,19 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus partially open before lift position event", function() test.socket.capability:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + -- timer fires after 2s with no position report to cancel it, so driver reads position + test.socket.matter:__expect_send( + {mock_device.id, build_position_read_request(mock_device, 10)} + ) + test.mock_time.advance_time(2) + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -516,12 +603,19 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus partially open before tilt position event", function() test.socket.capability:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + -- timer fires after 2s with no position report to cancel it, so driver reads position + test.socket.matter:__expect_send( + {mock_device.id, build_position_read_request(mock_device, 10)} + ) + test.mock_time.advance_time(2) + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -546,6 +640,94 @@ test.register_coroutine_test( } ) +test.register_coroutine_test( + "OperationalStatus stops without position report: timer fires and reads position (the bug fix)", function() + test.socket.capability:__set_channel_ordering("relaxed") + -- Device starts closing + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 2), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.closing() + ) + ) + test.wait_for_events() + -- OperationalStatus returns to 0 but no position report arrives; schedule the timer + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + } + ) + test.wait_for_events() + -- After 2 seconds, no position report arrived so driver reads position + test.socket.matter:__expect_send( + {mock_device.id, build_position_read_request(mock_device, 10)} + ) + test.mock_time.advance_time(2) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "OperationalStatus stops and position report arrives before timer: no redundant read", function() + test.socket.capability:__set_channel_ordering("relaxed") + -- Device starts opening + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 1), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.opening() + ) + ) + test.wait_for_events() + -- OperationalStatus returns to 0, timer scheduled + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), + } + ) + test.wait_for_events() + -- Position report arrives before timer fires, cancels timer + test.socket.matter:__queue_receive( + { + mock_device.id, + WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( + mock_device, 10, 0 + ), + } + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShadeLevel.shadeLevel(100) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.open() + ) + ) + test.wait_for_events() + -- Timer fires but shade is already "open" and timer was cancelled; no read sent + test.mock_time.advance_time(2) + end, + { + min_api_version = 17 + } +) + test.register_coroutine_test("WindowCovering OperationalStatus opening", function() test.socket.capability:__set_channel_ordering("relaxed") test.socket.matter:__queue_receive( @@ -821,6 +1003,7 @@ test.register_coroutine_test("OperationalStatus report contains current position mock_device, 10, ((100 - 25) *100) ) table.insert(report.info_blocks, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0).info_blocks[1]) + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.matter:__queue_receive({ mock_device.id, report}) test.socket.capability:__expect_send( mock_device:generate_test_message( @@ -832,6 +1015,12 @@ test.register_coroutine_test("OperationalStatus report contains current position "main", capabilities.windowShade.windowShade.partially_open() ) ) + -- position handler ran before status handler (no timer to cancel); + -- timer fires after 2s and re-reads position (redundant but harmless for well-behaved devices) + test.socket.matter:__expect_send( + {mock_device.id, build_position_read_request(mock_device, 10)} + ) + test.mock_time.advance_time(2) end, { min_api_version = 17 @@ -923,12 +1112,19 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering shade level adjusted by greater than 2%; status reflects Closing followed by Partially Open", function() test.socket.capability:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + -- timer fires after 2s with no position report to cancel it, so driver reads position + test.socket.matter:__expect_send( + {mock_device.id, build_position_read_request(mock_device, 10)} + ) + test.mock_time.advance_time(2) + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -1035,12 +1231,19 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering shade level adjusted by less than or equal to 2%; status reflects Closing followed by Partially Open", function() test.socket.capability:__set_channel_ordering("relaxed") + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") test.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + -- timer fires after 2s with no position report to cancel it, so driver reads position + test.socket.matter:__expect_send( + {mock_device.id, build_position_read_request(mock_device, 10)} + ) + test.mock_time.advance_time(2) + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id,