diff --git a/drivers/SmartThings/matter-window-covering/src/init.lua b/drivers/SmartThings/matter-window-covering/src/init.lua index ea7b89aa39..26ad044d6f 100644 --- a/drivers/SmartThings/matter-window-covering/src/init.lua +++ b/drivers/SmartThings/matter-window-covering/src/init.lua @@ -1,4 +1,4 @@ --- Copyright 2022 SmartThings +-- Copyright 2025 SmartThings -- -- Licensed under the Apache License, Version 2.0 (the "License"); -- you may not use this file except in compliance with the License. @@ -15,19 +15,28 @@ --Note: Currently only support for window shades with the PositionallyAware Feature --Note: No support for setting device into calibration mode, it must be done manually local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" local im = require "st.matter.interaction_model" 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 REVERSE_POLARITY = "__reverse_polarity" +local STATE_MACHINE = "__state_machine" + +local StateMachineEnum = { + STATE_IDLE = 0x00, + STATE_MOVING = 0x01, + STATE_OPERATIONAL_STATE_FIRED = 0x02, + STATE_CURRENT_POSITION_FIRED = 0x03 +} + local battery_support = { NO_BATTERY = "NO_BATTERY", BATTERY_LEVEL = "BATTERY_LEVEL", BATTERY_PERCENTAGE = "BATTERY_PERCENTAGE" } -local REVERSE_POLARITY = "__reverse_polarity" local function find_default_endpoint(device, cluster) local res = device.MATTER_DEFAULT_ENDPOINT @@ -188,59 +197,63 @@ local function handle_shade_tilt_level(driver, device, cmd) device:send(req) end +--- 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 function update_shade_status(device, endpoint_id, lift_position, tilt_position) + local windowShade = capabilities.windowShade.windowShade + if lift_position == nil then + if tilt_position == 0 then + device:emit_event_for_endpoint(endpoint_id, windowShade.closed()) + elseif tilt_position == 100 then + device:emit_event_for_endpoint(endpoint_id, windowShade.open()) + else + device:emit_event_for_endpoint(endpoint_id, windowShade.partially_open()) + end + elseif lift_position == 100 then + device:emit_event_for_endpoint(endpoint_id, 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, windowShade.closed()) + elseif tilt_position > 0 then + device:emit_event_for_endpoint(endpoint_id, windowShade.partially_open()) + end + else + device:emit_event_for_endpoint(endpoint_id, windowShade.unknown()) + end +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 + if ib.data.value == nil then return end local position = reverse_polarity_if_needed(device, math.floor((ib.data.value / 100))) device:emit_event_for_endpoint(ib.endpoint_id, attribute(position)) if attribute == capabilities.windowShadeLevel.shadeLevel then device:set_field(CURRENT_LIFT, position) - else + else -- attribute = capabilities.windowShadeTiltLevel.shadeTiltLevel device:set_field(CURRENT_TILT, position) 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, windowShade.closed()) - elseif tilt_position == 100 then - device:emit_event_for_endpoint(ib.endpoint_id, 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, 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, windowShade.closed()) - elseif tilt_position > 0 then - device:emit_event_for_endpoint(ib.endpoint_id, windowShade.partially_open()) - end + local state_machine = device:get_field(STATE_MACHINE) + -- When state_machine is STATE_IDLE or STATE_CURRENT_POSITION_FIRED, nothing to do + if state_machine == StateMachineEnum.STATE_MOVING then + device:set_field(STATE_MACHINE, StateMachineEnum.STATE_CURRENT_POSITION_FIRED) + elseif state_machine == StateMachineEnum.STATE_OPERATIONAL_STATE_FIRED or state_machine == nil then + update_shade_status(device, ib.endpoint_id, device:get_field(CURRENT_LIFT), device:get_field(CURRENT_TILT)) + device:set_field(STATE_MACHINE, StateMachineEnum.STATE_IDLE) end end end @@ -249,12 +262,30 @@ end local function current_status_handler(driver, device, ib, response) local windowShade = capabilities.windowShade.windowShade local state = ib.data.value & clusters.WindowCovering.types.OperationalStatus.GLOBAL - if state == 1 then -- opening - device:emit_event_for_endpoint(ib.endpoint_id, windowShade.opening()) - elseif state == 2 then -- closing - device:emit_event_for_endpoint(ib.endpoint_id, windowShade.closing()) - elseif state ~= 0 then -- unknown - device:emit_event_for_endpoint(ib.endpoint_id, windowShade.unknown()) + local state_machine = device:get_field(STATE_MACHINE) + -- When state_machine is STATE_OPERATIONAL_STATE_FIRED, nothing to do + if state_machine == StateMachineEnum.STATE_IDLE then + if state == 1 then -- opening + device:emit_event_for_endpoint(ib.endpoint_id, windowShade.opening()) + device:set_field(STATE_MACHINE, StateMachineEnum.STATE_MOVING) + elseif state == 2 then -- closing + device:emit_event_for_endpoint(ib.endpoint_id, windowShade.closing()) + device:set_field(STATE_MACHINE, StateMachineEnum.STATE_MOVING) + end + elseif state_machine == StateMachineEnum.STATE_MOVING then + if state == 0 then + device:set_field(STATE_MACHINE, StateMachineEnum.STATE_OPERATIONAL_STATE_FIRED) + elseif state == 1 then -- opening + device:emit_event_for_endpoint(ib.endpoint_id, windowShade.opening()) + elseif state == 2 then -- closing + device:emit_event_for_endpoint(ib.endpoint_id, windowShade.closing()) + else + device:emit_event_for_endpoint(ib.endpoint_id, windowShade.unknown()) + device:set_field(STATE_MACHINE, StateMachineEnum.STATE_IDLE) + end + elseif state_machine == StateMachineEnum.STATE_CURRENT_POSITION_FIRED then + update_shade_status(device, ib.endpoint_id, device:get_field(CURRENT_LIFT), device:get_field(CURRENT_TILT)) + device:set_field(STATE_MACHINE, StateMachineEnum.STATE_IDLE) end end @@ -367,10 +398,6 @@ local matter_driver_template = { capabilities.windowShadePreset, capabilities.battery, capabilities.batteryLevel, - }, - sub_drivers = { - -- for devices sending a position update while device is in motion - require("matter-window-covering-position-updates-while-moving") } } diff --git a/drivers/SmartThings/matter-window-covering/src/matter-window-covering-position-updates-while-moving/init.lua b/drivers/SmartThings/matter-window-covering/src/matter-window-covering-position-updates-while-moving/init.lua deleted file mode 100644 index b337e31bca..0000000000 --- a/drivers/SmartThings/matter-window-covering/src/matter-window-covering-position-updates-while-moving/init.lua +++ /dev/null @@ -1,157 +0,0 @@ --- Copyright 2023 SmartThings --- --- Licensed under the Apache License, Version 2.0 (the "License"); --- you may not use this file except in compliance with the License. --- You may obtain a copy of the License at --- --- http://www.apache.org/licenses/LICENSE-2.0 --- --- Unless required by applicable law or agreed to in writing, software --- distributed under the License is distributed on an "AS IS" BASIS, --- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. --- See the License for the specific language governing permissions and --- limitations under the License. - -local capabilities = require "st.capabilities" -local clusters = require "st.matter.clusters" -local device_lib = require "st.device" - -local DEFAULT_LEVEL = 0 -local STATE_MACHINE = "__state_machine" -local REVERSE_POLARITY = "__reverse_polarity" - -local StateMachineEnum = { - STATE_IDLE = 0x00, - STATE_MOVING = 0x01, - STATE_OPERATIONAL_STATE_FIRED = 0x02, - STATE_CURRENT_POSITION_FIRED = 0x03 -} - -local SUB_WINDOW_COVERING_VID_PID = { - {0x10e1, 0x1005} -- VDA -} - -local function is_matter_window_covering_position_updates_while_moving(opts, driver, device) - if device.network_type ~= device_lib.NETWORK_TYPE_MATTER then - return false - end - for i, v in ipairs(SUB_WINDOW_COVERING_VID_PID) do - if device.manufacturer_info.vendor_id == v[1] and - device.manufacturer_info.product_id == v[2] then - return true - end - end - return false -end - -local function reverse_polarity_if_needed(device, value) - if device:get_field(REVERSE_POLARITY) then - return value - end - return 100 - value -end - -local function device_init(driver, device) - device:subscribe() -end - --- current lift percentage, changed to 100ths percent -local function current_pos_handler(driver, device, ib, response) - local position = 0 - if ib.data.value ~= nil then - position = reverse_polarity_if_needed(device, math.floor((ib.data.value / 100))) - device:emit_event_for_endpoint( - ib.endpoint_id, capabilities.windowShadeLevel.shadeLevel(position) - ) - end - local state_machine = device:get_field(STATE_MACHINE) - -- When stat_machine is STATE_IDLE or STATE_CURRENT_POSITION_FIRED, nothing to do - if state_machine == StateMachineEnum.STATE_MOVING then - device:set_field(STATE_MACHINE, StateMachineEnum.STATE_CURRENT_POSITION_FIRED) - elseif state_machine == StateMachineEnum.STATE_OPERATIONAL_STATE_FIRED or state_machine == nil then - if position == 0 then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.windowShade.windowShade.closed()) - elseif position == 100 then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.windowShade.windowShade.open()) - elseif position > 0 and position < 100 then - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.windowShade.windowShade.partially_open()) - else - device:emit_event_for_endpoint(ib.endpoint_id, capabilities.windowShade.windowShade.unknown()) - end - device:set_field(STATE_MACHINE, StateMachineEnum.STATE_IDLE) - end -end - --- checks the current position of the shade -local function current_status_handler(driver, device, ib, response) - local attr = capabilities.windowShade.windowShade - local position = device:get_latest_state( - "main", capabilities.windowShadeLevel.ID, - capabilities.windowShadeLevel.shadeLevel.NAME - ) or DEFAULT_LEVEL - position = reverse_polarity_if_needed(device, position) - for _, rb in ipairs(response.info_blocks) do - if rb.info_block.attribute_id == clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID and - rb.info_block.cluster_id == clusters.WindowCovering.ID and - rb.info_block.data ~= nil and - rb.info_block.data.value ~= nil then - position = reverse_polarity_if_needed(device, math.floor((rb.info_block.data.value / 100))) - end - end - local state = ib.data.value & clusters.WindowCovering.types.OperationalStatus.GLOBAL --Could use LIFT instead - local state_machine = device:get_field(STATE_MACHINE) - -- When stat_machine is STATE_OPERATIONAL_STATE_FIRED, nothing to do - if state_machine == StateMachineEnum.STATE_IDLE then - if state == 1 then -- opening - device:emit_event_for_endpoint(ib.endpoint_id, attr.opening()) - device:set_field(STATE_MACHINE, StateMachineEnum.STATE_MOVING) - elseif state == 2 then -- closing - device:emit_event_for_endpoint(ib.endpoint_id, attr.closing()) - device:set_field(STATE_MACHINE, StateMachineEnum.STATE_MOVING) - end - elseif state_machine == StateMachineEnum.STATE_MOVING then - if state == 0 then -- not moving - device:set_field(STATE_MACHINE, StateMachineEnum.STATE_OPERATIONAL_STATE_FIRED) - elseif state == 1 then -- opening - device:emit_event_for_endpoint(ib.endpoint_id, attr.opening()) - elseif state == 2 then -- closing - device:emit_event_for_endpoint(ib.endpoint_id, attr.closing()) - else - device:emit_event_for_endpoint(ib.endpoint_id, attr.unknown()) - device:set_field(STATE_MACHINE, StateMachineEnum.STATE_IDLE) - end - elseif state_machine == StateMachineEnum.STATE_CURRENT_POSITION_FIRED then - if state == 0 then -- not moving - if position == 100 then - device:emit_event_for_endpoint(ib.endpoint_id, attr.open()) - elseif position == 0 then - device:emit_event_for_endpoint(ib.endpoint_id, attr.closed()) - else - device:emit_event_for_endpoint(ib.endpoint_id, attr.partially_open()) - end - else - device:emit_event_for_endpoint(ib.endpoint_id, attr.unknown()) - end - device:set_field(STATE_MACHINE, StateMachineEnum.STATE_IDLE) - end -end - -local matter_window_covering_position_updates_while_moving_handler = { - NAME = "matter-window-covering-position-updates-while-moving", - lifecycle_handlers = { - init = device_init, - }, - matter_handlers = { - attr = { - [clusters.WindowCovering.ID] = { - [clusters.WindowCovering.attributes.CurrentPositionLiftPercent100ths.ID] = current_pos_handler, - [clusters.WindowCovering.attributes.OperationalStatus.ID] = current_status_handler, - } - } - }, - capability_handlers = { - }, - can_handle = is_matter_window_covering_position_updates_while_moving, -} - -return matter_window_covering_position_updates_while_moving_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 ed9d81f7f1..66ccd3857a 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 @@ -1,4 +1,4 @@ --- Copyright 2022 SmartThings +-- Copyright 2025 SmartThings -- -- Licensed under the Apache License, Version 2.0 (the "License"); -- you may not use this file except in compliance with the License. @@ -611,6 +611,17 @@ test.register_coroutine_test("WindowCovering OperationalStatus unknown", functio "main", capabilities.windowShade.windowShade.partially_open() ) ) + 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.socket.matter:__queue_receive( { mock_device.id, @@ -898,11 +909,6 @@ test.register_coroutine_test( "main", capabilities.windowShadeLevel.shadeLevel(23) ) ) - 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( { @@ -917,11 +923,6 @@ test.register_coroutine_test( "main", capabilities.windowShadeLevel.shadeLevel(21) ) ) - 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( { @@ -936,6 +937,12 @@ test.register_coroutine_test( "main", capabilities.windowShadeLevel.shadeLevel(19) ) ) + 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() @@ -1007,6 +1014,12 @@ test.register_coroutine_test( "main", capabilities.windowShadeLevel.shadeLevel(23) ) ) + 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() @@ -1053,11 +1066,6 @@ test.register_coroutine_test( "main", capabilities.windowShadeLevel.shadeLevel(60) ) ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.partially_open() - ) - ) test.socket.capability:__queue_receive( { mock_device.id, @@ -1108,11 +1116,6 @@ test.register_coroutine_test( "main", capabilities.windowShadeTiltLevel.shadeTiltLevel(35) ) ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( - "main", capabilities.windowShade.windowShade.partially_open() - ) - ) test.socket.capability:__queue_receive( { mock_device.id,