diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_handlers/event_handlers.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_handlers/event_handlers.lua index da9b1c0392..2a23eda4fe 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_handlers/event_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_handlers/event_handlers.lua @@ -6,28 +6,42 @@ local capabilities = require "st.capabilities" local switch_utils = require "switch_utils.utils" local generic_event_handlers = require "switch_handlers.event_handlers" local scroll_fields = require "sub_drivers.ikea_scroll.scroll_utils.fields" +local event_utils = require "sub_drivers.ikea_scroll.scroll_utils.event_utils" local IkeaScrollEventHandlers = {} local function rotate_amount_event_helper(device, endpoint_id, num_presses_to_handle) + if num_presses_to_handle <= 0 then return end + -- to cut down on checks, we can assume that if the endpoint is not in ENDPOINTS_UP_SCROLL, it is in ENDPOINTS_DOWN_SCROLL local scroll_direction = switch_utils.tbl_contains(scroll_fields.ENDPOINTS_UP_SCROLL, endpoint_id) and 1 or -1 local scroll_amount = st_utils.clamp_value(scroll_direction * scroll_fields.PER_SCROLL_EVENT_ROTATION * num_presses_to_handle, -100, 100) - device:emit_event_for_endpoint(endpoint_id, capabilities.knob.rotateAmount(scroll_amount, {state_change = true})) + + if event_utils.is_valid_scroll_amount(device, scroll_amount) then + device:emit_event_for_endpoint(endpoint_id, capabilities.knob.rotateAmount(scroll_amount, {state_change = true})) + end end -- Used by ENDPOINTS_UP_SCROLL and ENDPOINTS_DOWN_SCROLL, not ENDPOINTS_PUSH function IkeaScrollEventHandlers.multi_press_ongoing_handler(driver, device, ib, response) if switch_utils.tbl_contains(scroll_fields.ENDPOINTS_PUSH, ib.endpoint_id) then - -- Ignore MultiPressOngoing events from push endpoints. device.log.debug("Received MultiPressOngoing event from push endpoint, ignoring.") else - local cur_num_presses_counted = ib.data and ib.data.elements and ib.data.elements.current_number_of_presses_counted.value or 0 - local num_presses_to_handle = cur_num_presses_counted - (device:get_field(scroll_fields.LATEST_NUMBER_OF_PRESSES_COUNTED) or 0) - if num_presses_to_handle > 0 then - device:set_field(scroll_fields.LATEST_NUMBER_OF_PRESSES_COUNTED, cur_num_presses_counted) - rotate_amount_event_helper(device, ib.endpoint_id, num_presses_to_handle) + local cur_num_presses_counted = ib.data.elements and ib.data.elements.current_number_of_presses_counted.value or 0 + local cur_multi_press_count = cur_num_presses_counted + if #response.info_blocks > 1 then + if event_utils.is_last_valid_info_block(ib.event_id, cur_num_presses_counted, response.info_blocks) then + local aggregated_presses = event_utils.aggregate_scroll_amount_for_info_blocks(device, response.info_blocks) or {} + cur_num_presses_counted = aggregated_presses.total_presses or 0 + cur_multi_press_count = aggregated_presses.presses_in_current_chain or 0 + else + device.log.debug("Received MultiPressOngoing event that is not the last valid info block, ignoring.") + return + end end + local num_presses_to_handle = cur_num_presses_counted - (device:get_field(scroll_fields.LATEST_NUMBER_OF_PRESSES_HANDLED) or 0) + device:set_field(scroll_fields.LATEST_NUMBER_OF_PRESSES_HANDLED, cur_multi_press_count) + rotate_amount_event_helper(device, ib.endpoint_id, num_presses_to_handle) end end @@ -35,13 +49,20 @@ function IkeaScrollEventHandlers.multi_press_complete_handler(driver, device, ib if switch_utils.tbl_contains(scroll_fields.ENDPOINTS_PUSH, ib.endpoint_id) then generic_event_handlers.multi_press_complete_handler(driver, device, ib, response) else - local total_num_presses_counted = ib.data and ib.data.elements and ib.data.elements.total_number_of_presses_counted.value or 0 - local num_presses_to_handle = total_num_presses_counted - (device:get_field(scroll_fields.LATEST_NUMBER_OF_PRESSES_COUNTED) or 0) - if num_presses_to_handle > 0 then - rotate_amount_event_helper(device, ib.endpoint_id, num_presses_to_handle) + local total_num_presses_counted = ib.data.elements and ib.data.elements.total_number_of_presses_counted.value or 0 + if #response.info_blocks > 1 then + if event_utils.is_last_valid_info_block(ib.event_id, total_num_presses_counted, response.info_blocks) then + local aggregated_presses = event_utils.aggregate_scroll_amount_for_info_blocks(device, response.info_blocks) or {} + total_num_presses_counted = aggregated_presses.total_presses or 0 + else + device.log.debug("Received MultiPressComplete event that is not the last valid info block, ignoring.") + return + end end - -- reset the LATEST_NUMBER_OF_PRESSES_COUNTED to nil at the end of a MultiPress chain. - device:set_field(scroll_fields.LATEST_NUMBER_OF_PRESSES_COUNTED, nil) + local num_presses_to_handle = total_num_presses_counted - (device:get_field(scroll_fields.LATEST_NUMBER_OF_PRESSES_HANDLED) or 0) + rotate_amount_event_helper(device, ib.endpoint_id, num_presses_to_handle) + -- always reset the LATEST_NUMBER_OF_PRESSES_HANDLED to nil at the end of a handled MultiPress chain. + device:set_field(scroll_fields.LATEST_NUMBER_OF_PRESSES_HANDLED, nil) end end @@ -49,10 +70,14 @@ function IkeaScrollEventHandlers.initial_press_handler(driver, device, ib, respo if switch_utils.tbl_contains(scroll_fields.ENDPOINTS_PUSH, ib.endpoint_id) then generic_event_handlers.initial_press_handler(driver, device, ib, response) else - -- the magic number "1" occurs in this handler since the InitialPress event represents the first press. - local latest_presses_counted = device:get_field(scroll_fields.LATEST_NUMBER_OF_PRESSES_COUNTED) or 0 - if latest_presses_counted == 0 then - device:set_field(scroll_fields.LATEST_NUMBER_OF_PRESSES_COUNTED, 1) + if #response.info_blocks > 1 then + device.log.debug("Received InitialPress event in response with multiple info blocks, ignoring due to event order ambiguity.") + return + end + local latest_presses_handled = device:get_field(scroll_fields.LATEST_NUMBER_OF_PRESSES_HANDLED) or 0 + if latest_presses_handled == 0 then + -- the magic number "1" is here since the InitialPress event when presses handled is 0 represents the first press. + device:set_field(scroll_fields.LATEST_NUMBER_OF_PRESSES_HANDLED, 1) rotate_amount_event_helper(device, ib.endpoint_id, 1) end end diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/event_utils.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/event_utils.lua new file mode 100644 index 0000000000..b991805cec --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/event_utils.lua @@ -0,0 +1,75 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local st_utils = require "st.utils" +local clusters = require "st.matter.clusters" +local scroll_fields = require "sub_drivers.ikea_scroll.scroll_utils.fields" + +local IkeaScrollEventUtils = {} + + +function IkeaScrollEventUtils.requeue_clear_scroll_state(device) + -- Stores a timer object, which is required to cancel a timer early + local CLEAR_STATE_TIMER = "__clear_state_timer" + -- cancel any previously queued clear state actions to prevent unintended clears + if device:get_field(CLEAR_STATE_TIMER) then + device.thread:cancel_timer(device:get_field(CLEAR_STATE_TIMER)) + end + local delay_s = 8 + local new_timer = device.thread:call_with_delay(delay_s, function() + device:set_field(scroll_fields.GLOBAL_ROTATE_AMOUNT_STATE, 0) + end) + device:set_field(CLEAR_STATE_TIMER, new_timer) +end + +function IkeaScrollEventUtils.is_valid_scroll_amount(device, scroll_amount) + local global_rotate_amount_state = device:get_field(scroll_fields.GLOBAL_ROTATE_AMOUNT_STATE) or 0 + local is_rotate_amount_state_at_bounds = (scroll_amount < 0 and global_rotate_amount_state <= -100) or (scroll_amount > 0 and global_rotate_amount_state >= 100) + if is_rotate_amount_state_at_bounds then + return false + end + + device:set_field(scroll_fields.GLOBAL_ROTATE_AMOUNT_STATE, st_utils.clamp_value(global_rotate_amount_state + scroll_amount, -100, 100)) + IkeaScrollEventUtils.requeue_clear_scroll_state(device) + return true +end + +-- inspect all info blocks to find the last one that is not an InitialPress event. We will +-- only try to emit a rotateAmount event if the current info block being handled is that last one. +function IkeaScrollEventUtils.is_last_valid_info_block(cur_info_block_event_id, cur_info_block_value, info_blocks) + local last_valid_emission_idx = #info_blocks + while (last_valid_emission_idx > 1) and (info_blocks[last_valid_emission_idx].info_block.event_id == clusters.Switch.events.InitialPress) do + last_valid_emission_idx = last_valid_emission_idx - 1 + end + local emission_ib = info_blocks[last_valid_emission_idx].info_block + if emission_ib.event_id ~= cur_info_block_event_id then + return false + elseif emission_ib.event_id == clusters.Switch.events.MultiPressComplete.ID then + local last_valid_ib_value = emission_ib.data.elements and emission_ib.data.elements.total_number_of_presses_counted.value or 0 + return last_valid_ib_value == cur_info_block_value + elseif emission_ib.event_id == clusters.Switch.events.MultiPressOngoing.ID then + local last_valid_ib_value = emission_ib.data.elements and emission_ib.data.elements.current_number_of_presses_counted.value or 0 + return last_valid_ib_value == cur_info_block_value + elseif last_valid_emission_idx == 1 then -- aka, all ib's are InitialPress + return true + end + + return false +end + +function IkeaScrollEventUtils.aggregate_scroll_amount_for_info_blocks(device, info_blocks) + local total_presses = 0 + local presses_in_current_chain = 0 + for _, ib in ipairs(info_blocks) do + if ib.info_block.event_id == clusters.Switch.events.MultiPressOngoing.ID then + presses_in_current_chain = presses_in_current_chain + (ib.info_block.data.elements and ib.info_block.data.elements.current_number_of_presses_counted.value or 0) + elseif ib.info_block.event_id == clusters.Switch.events.MultiPressComplete.ID then + total_presses = total_presses + (ib.info_block.data.elements and ib.info_block.data.elements.total_number_of_presses_counted.value or 0) + presses_in_current_chain = 0 + end + end + total_presses = total_presses + presses_in_current_chain -- aggregate any presses to the total from the current chain + return { total_presses = total_presses, presses_in_current_chain = presses_in_current_chain } +end + +return IkeaScrollEventUtils diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/fields.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/fields.lua index 5e98a829c0..dece027c87 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/fields.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/fields.lua @@ -22,10 +22,13 @@ IkeaScrollFields.ENDPOINTS_DOWN_SCROLL = {2, 5, 8} IkeaScrollFields.MAX_SCROLL_PRESSES = 18 -- Amount to rotate per scroll event -IkeaScrollFields.PER_SCROLL_EVENT_ROTATION = st_utils.round(1 / IkeaScrollFields.MAX_SCROLL_PRESSES * 100) +IkeaScrollFields.PER_SCROLL_EVENT_ROTATION = 6 -- st_utils.round(1 / IkeaScrollFields.MAX_SCROLL_PRESSES * 100) --- Field to track the latest number of presses counted during a single scroll event sequence -IkeaScrollFields.LATEST_NUMBER_OF_PRESSES_COUNTED = "__latest_number_of_presses_counted" +-- Field to track the latest number of presses handled during a single scroll event sequence +IkeaScrollFields.LATEST_NUMBER_OF_PRESSES_HANDLED = "__latest_number_of_presses_handled" + + +IkeaScrollFields.GLOBAL_ROTATE_AMOUNT_STATE = "__global_rotate_amount_state" -- Required Events for the ENDPOINTS_PUSH. IkeaScrollFields.switch_press_subscribed_events = {