From e36473765585ee8c1007a6feffba8d85d2d1b534 Mon Sep 17 00:00:00 2001 From: Harrison Carter Date: Wed, 24 Jun 2026 13:28:27 -0500 Subject: [PATCH] Directly use latest shade level state if not running --- .../matter-window-covering/src/init.lua | 179 ++++++++---------- .../src/test/test_matter_window_covering.lua | 121 ++++++++---- 2 files changed, 165 insertions(+), 135 deletions(-) diff --git a/drivers/SmartThings/matter-window-covering/src/init.lua b/drivers/SmartThings/matter-window-covering/src/init.lua index 66581d0ae8..9fcd9b895c 100644 --- a/drivers/SmartThings/matter-window-covering/src/init.lua +++ b/drivers/SmartThings/matter-window-covering/src/init.lua @@ -10,15 +10,11 @@ local log = require "log" local clusters = require "st.matter.clusters" local MatterDriver = require "st.matter.driver" -local CURRENT_LIFT = "__current_lift" -local CURRENT_TILT = "__current_tilt" local battery_support = { NO_BATTERY = "NO_BATTERY", BATTERY_LEVEL = "BATTERY_LEVEL", BATTERY_PERCENTAGE = "BATTERY_PERCENTAGE" } -local REVERSE_POLARITY = "__reverse_polarity" -local PRESET_LEVEL_KEY = "__preset_level_key" local DEFAULT_PRESET_LEVEL = 50 local function find_default_endpoint(device, cluster) @@ -60,17 +56,18 @@ local function match_profile(device, battery_supported) end local function device_init(driver, device) + -- set unused fields to nil. These field settings can be removed after a single driver update. + device:set_field("__preset_level_key", nil) + device:set_field("__reverse_polarity", nil) + device:set_component_to_endpoint_fn(component_to_endpoint) if device:supports_capability_by_id(capabilities.windowShadePreset.ID) and device:get_latest_state("main", capabilities.windowShadePreset.ID, capabilities.windowShadePreset.position.NAME) == nil then -- These should only ever be nil once (and at the same time) for already-installed devices -- It can be removed after migration is complete device:emit_event(capabilities.windowShadePreset.supportedCommands({"presetPosition", "setPresetPosition"}, {visibility = {displayed = false}})) - local preset_position = device:get_field(PRESET_LEVEL_KEY) or - (device.preferences ~= nil and device.preferences.presetPosition) or - DEFAULT_PRESET_LEVEL + local preset_position = device.preferences.presetPosition or DEFAULT_PRESET_LEVEL device:emit_event(capabilities.windowShadePreset.position(preset_position, {visibility = {displayed = false}})) - device:set_field(PRESET_LEVEL_KEY, preset_position, {persist = true}) end device:subscribe() end @@ -88,17 +85,8 @@ end local function info_changed(driver, device, event, args) if device.profile.id ~= args.old_st_store.profile.id then - -- Profile has changed, resubscribe device:subscribe() - elseif args.old_st_store.preferences.reverse ~= device.preferences.reverse then - if device.preferences.reverse then - device:set_field(REVERSE_POLARITY, true, { persist = true }) - else - device:set_field(REVERSE_POLARITY, false, { persist = true }) - end - else - -- Something else has changed info (SW update, reinterview, etc.), so - -- try updating profile as needed + elseif device.matter_version.software ~= args.old_st_store.matter_version.software then local battery_feature_eps = device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) if #battery_feature_eps > 0 then local attribute_list_read = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) @@ -114,7 +102,6 @@ local function device_added(driver, device) device:emit_event( capabilities.windowShade.supportedWindowShadeCommands({"open", "close", "pause"}, {visibility = {displayed = false}}) ) - device:set_field(REVERSE_POLARITY, false, { persist = true }) end local function device_removed(driver, device) log.info("device removed") end @@ -133,35 +120,31 @@ end local function handle_set_preset(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) - device:set_field(PRESET_LEVEL_KEY, cmd.args.position) device:emit_event_for_endpoint(endpoint_id, capabilities.windowShadePreset.position(cmd.args.position)) end -- close covering local function handle_close(driver, device, cmd) 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 - req = clusters.WindowCovering.server.commands.UpOrOpen(device, endpoint_id) - end - device:send(req) + device:send(device.preferences.reverse and + clusters.WindowCovering.server.commands.UpOrOpen(device, endpoint_id) or + clusters.WindowCovering.server.commands.DownOrClose(device, endpoint_id) + ) end -- open covering local function handle_open(driver, device, cmd) 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 - req = clusters.WindowCovering.server.commands.DownOrClose(device, endpoint_id) - end - device:send(req) + device:send(device.preferences.reverse and + clusters.WindowCovering.server.commands.DownOrClose(device, endpoint_id) or + clusters.WindowCovering.server.commands.UpOrOpen(device, endpoint_id) + ) end -- pause covering local function handle_pause(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) - local req = clusters.WindowCovering.server.commands.StopMotion(device, endpoint_id) - device:send(req) + device:send(clusters.WindowCovering.server.commands.StopMotion(device, endpoint_id)) end -- move to shade level between 0-100 @@ -169,10 +152,9 @@ local function handle_shade_level(driver, device, cmd) 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 - local req = clusters.WindowCovering.server.commands.GoToLiftPercentage( - device, endpoint_id, hundredths_lift_percentage - ) - device:send(req) + device:send(clusters.WindowCovering.server.commands.GoToLiftPercentage( + device, endpoint_id, hundredths_lift_percentage + )) end -- move to shade tilt level between 0-100 @@ -180,80 +162,85 @@ local function handle_shade_tilt_level(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) local tilt_percentage_value = 100 - cmd.args.level local hundredths_tilt_percentage = tilt_percentage_value * 100 - local req = clusters.WindowCovering.server.commands.GoToTiltPercentage( + device:send(clusters.WindowCovering.server.commands.GoToTiltPercentage( device, endpoint_id, hundredths_tilt_percentage - ) - device:send(req) + )) end --- current lift/tilt percentage, changed to 100ths percent -local current_pos_handler = function(attribute) - return function(driver, device, ib, response) - if ib.data.value == nil then - return - end - local windowShade = capabilities.windowShade.windowShade - local position = 100 - math.floor(ib.data.value / 100) - local reverse = device:get_field(REVERSE_POLARITY) - device:emit_event_for_endpoint(ib.endpoint_id, attribute(position)) +local function update_window_shade_status(device, endpoint_id) + -- Update the window shade status according to the lift and tilt positions. + -- LIFT TILT Window Shade + -- 100 any Open + -- 1-99 any Partially Open + -- 0 1-100 Partially Open + -- 0 0 Closed + -- 0 nil Closed + -- nil 100 Open + -- nil 1-99 Partially Open + -- nil 0 Closed + -- Note that lift or tilt may be nil if either the window shade does not + -- support them or if they haven't been received from a device report yet. + + local lift_position = device:get_latest_state( + "main", + capabilities.windowShadeLevel.ID, + capabilities.windowShadeLevel.shadeLevel.NAME + ) + local tilt_position = device:get_latest_state( + "main", + capabilities.windowShadeTiltLevel.ID, + capabilities.windowShadeTiltLevel.shadeTiltLevel.NAME + ) - if attribute == capabilities.windowShadeLevel.shadeLevel then - device:set_field(CURRENT_LIFT, position) + local reverse = device.preferences.reverse + local windowShade = capabilities.windowShade.windowShade + + if lift_position == nil then + if tilt_position == 0 then + device:emit_event_for_endpoint(endpoint_id, reverse and windowShade.open() or windowShade.closed()) + elseif tilt_position == 100 then + device:emit_event_for_endpoint(endpoint_id, reverse and windowShade.closed() or windowShade.open()) else - device:set_field(CURRENT_TILT, position) + device:emit_event_for_endpoint(endpoint_id, windowShade.partially_open()) end - local lift_position = device:get_field(CURRENT_LIFT) - local tilt_position = device:get_field(CURRENT_TILT) - - -- Update the window shade status according to the lift and tilt positions. - -- LIFT TILT Window Shade - -- 100 any Open - -- 1-99 any Partially Open - -- 0 1-100 Partially Open - -- 0 0 Closed - -- 0 nil Closed - -- nil 100 Open - -- nil 1-99 Partially Open - -- nil 0 Closed - -- Note that lift or tilt may be nil if either the window shade does not - -- support them or if they haven't been received from a device report yet. - - if lift_position == nil then - if tilt_position == 0 then - device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.open() or windowShade.closed()) - elseif tilt_position == 100 then - device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.closed() or windowShade.open()) - else - device:emit_event_for_endpoint(ib.endpoint_id, windowShade.partially_open()) - end - - elseif lift_position == 100 then - device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.closed() or windowShade.open()) - - elseif lift_position > 0 then - device:emit_event_for_endpoint(ib.endpoint_id, windowShade.partially_open()) - - elseif lift_position == 0 then - if tilt_position == nil or tilt_position == 0 then - device:emit_event_for_endpoint(ib.endpoint_id, reverse and windowShade.open() or windowShade.closed()) - elseif tilt_position > 0 then - device:emit_event_for_endpoint(ib.endpoint_id, windowShade.partially_open()) - end + elseif lift_position == 100 then + device:emit_event_for_endpoint(endpoint_id, reverse and windowShade.closed() or windowShade.open()) + + elseif lift_position > 0 then + device:emit_event_for_endpoint(endpoint_id, windowShade.partially_open()) + + elseif lift_position == 0 then + if tilt_position == nil or tilt_position == 0 then + device:emit_event_for_endpoint(endpoint_id, reverse and windowShade.open() or windowShade.closed()) + elseif tilt_position > 0 then + device:emit_event_for_endpoint(endpoint_id, windowShade.partially_open()) end end end +-- current lift/tilt percentage, changed to 100ths percent +local current_position_handler = function(attribute) + return function(driver, device, ib, response) + if ib.data.value == nil then return end + local position = 100 - math.floor(ib.data.value / 100) + device:emit_event_for_endpoint(ib.endpoint_id, attribute(position)) + update_window_shade_status(device, ib.endpoint_id) + end +end + -- checks the current position of the shade -local function current_status_handler(driver, device, ib, response) +local function operational_status_handler(driver, device, ib, response) + local reverse = device.preferences.reverse local windowShade = capabilities.windowShade.windowShade - local reverse = device:get_field(REVERSE_POLARITY) local state = ib.data.value & clusters.WindowCovering.types.OperationalStatus.GLOBAL if state == 1 then -- opening 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 + update_window_shade_status(device, ib.endpoint_id) + else -- unknown device:emit_event_for_endpoint(ib.endpoint_id, windowShade.unknown()) end end @@ -283,7 +270,7 @@ local function battery_charge_level_attr_handler(driver, device, ib, response) end local function power_source_attribute_list_handler(driver, device, ib, response) - for _, attr in ipairs(ib.data.elements) do + for _, attr in ipairs(ib.data.elements or {}) do -- Re-profile the device if BatPercentRemaining (Attribute ID 0x0C) is present. if attr.value == 0x0C then match_profile(device, battery_support.BATTERY_PERCENTAGE) @@ -312,9 +299,9 @@ local matter_driver_template = { }, [clusters.WindowCovering.ID] = { --uses percent100ths more often - [clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID] = current_pos_handler(capabilities.windowShadeLevel.shadeLevel), - [clusters.WindowCovering.attributes.CurrentPositionTiltPercent100ths.ID] = current_pos_handler(capabilities.windowShadeTiltLevel.shadeTiltLevel), - [clusters.WindowCovering.attributes.OperationalStatus.ID] = current_status_handler, + [clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID] = current_position_handler(capabilities.windowShadeLevel.shadeLevel), + [clusters.WindowCovering.attributes.CurrentPositionTiltPercent100ths.ID] = current_position_handler(capabilities.windowShadeTiltLevel.shadeTiltLevel), + [clusters.WindowCovering.attributes.OperationalStatus.ID] = operational_status_handler, }, [clusters.PowerSource.ID] = { [clusters.PowerSource.attributes.AttributeList.ID] = power_source_attribute_list_handler, 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 cf2b9fd681..5324d0478b 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 @@ -21,6 +21,7 @@ local mock_device = test.mock_device.build_test_matter_device( { profile = t_utils.get_profile_definition("window-covering-tilt-battery.yml"), manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {software = 1}, endpoints = { { endpoint_id = 2, @@ -182,6 +183,11 @@ test.register_coroutine_test( WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.closed() + ) + ) end, { min_api_version = 17 @@ -215,6 +221,11 @@ test.register_coroutine_test( WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.closed() + ) + ) end, { min_api_version = 17 @@ -223,13 +234,18 @@ 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.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -256,13 +272,18 @@ 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.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -289,7 +310,6 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus state open following lift position update", function() - test.socket.capability:__set_channel_ordering("relaxed") test.socket.matter:__queue_receive( { mock_device.id, @@ -314,6 +334,11 @@ test.register_coroutine_test( WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.open() + ) + ) end, { min_api_version = 17 @@ -322,7 +347,6 @@ test.register_coroutine_test( test.register_coroutine_test( "WindowCovering OperationalStatus state open following tilt position update", function() - test.socket.capability:__set_channel_ordering("relaxed") test.socket.matter:__queue_receive( { mock_device.id, @@ -347,6 +371,11 @@ test.register_coroutine_test( WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.open() + ) + ) end, { min_api_version = 17 @@ -355,13 +384,18 @@ 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.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + test.wait_for_events() test.socket.capability:__expect_send( mock_device:generate_test_message( "main", capabilities.windowShadeLevel.shadeLevel(100) @@ -388,13 +422,18 @@ 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.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + test.wait_for_events() test.socket.capability:__expect_send( mock_device:generate_test_message( "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(100) @@ -440,12 +479,18 @@ 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), } ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) end, { min_api_version = 17 @@ -479,6 +524,11 @@ test.register_coroutine_test( WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) end, { min_api_version = 17 @@ -487,13 +537,18 @@ 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.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -520,13 +575,18 @@ 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.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -820,29 +880,6 @@ test.register_coroutine_test( } ) -test.register_coroutine_test("OperationalStatus report contains current position report", function() - test.socket.capability:__set_channel_ordering("relaxed") - local report = WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( - 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.socket.matter:__queue_receive({ mock_device.id, report}) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShadeLevel.shadeLevel(25) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.partially_open() - ) - ) -end, -{ - min_api_version = 17 -} -) - test.register_coroutine_test( "Handle preset commands", function() @@ -911,9 +948,9 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "InfoChanged event checks for new profile match if device has changed (i.e. through reinterview or SW update)", + "InfoChanged event updates new profile if sw updated has occurred", function() - test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({})) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ matter_version = {software = 2} })) local read_attribute_list = clusters.PowerSource.attributes.AttributeList:read() test.socket.matter:__expect_send({mock_device.id, read_attribute_list}) test.wait_for_events() @@ -927,13 +964,18 @@ 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.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -1039,13 +1081,18 @@ 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.socket.matter:__queue_receive( { mock_device.id, WindowCovering.attributes.OperationalStatus:build_test_report_data(mock_device, 10, 0), } ) + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.windowShade.windowShade.partially_open() + ) + ) + test.wait_for_events() test.socket.matter:__queue_receive( { mock_device.id, @@ -1115,8 +1162,6 @@ test.register_coroutine_test( "Check that preference updates to reverse polarity after being set to true and that the shade lift operates as expected when opening and closing", function() test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({ preferences = { reverse = "true" } })) test.wait_for_events() - local reverse_preference_set = mock_device:get_field("__reverse_polarity") - assert(reverse_preference_set == true, "reverse_preference_set is True") test.socket.matter:__queue_receive({ mock_device.id, WindowCovering.attributes.CurrentPositionLiftPercent100ths:build_test_report_data( @@ -1173,8 +1218,6 @@ test.register_coroutine_test( "Check that preference updates to reverse polarity after being set to true and that the shade tilt operates as expected when opening and closing", function() test.socket.device_lifecycle():__queue_receive(mock_device:generate_info_changed({ preferences = { reverse = "true" } })) test.wait_for_events() - local reverse_preference_set = mock_device:get_field("__reverse_polarity") - assert(reverse_preference_set == true, "reverse_preference_set is True") test.socket.matter:__queue_receive({ mock_device.id, WindowCovering.attributes.CurrentPositionTiltPercent100ths:build_test_report_data(