Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions drivers/SmartThings/matter-switch/profiles/ikea-scroll.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ components:
capabilities:
- id: button
version: 1
- id: knob
version: 1
- id: battery
version: 1
- id: firmwareUpdate
Expand All @@ -18,12 +20,16 @@ components:
capabilities:
- id: button
version: 1
- id: knob
version: 1
categories:
- name: RemoteController
- id: group3
label: Group 3
capabilities:
- id: button
version: 1
- id: knob
version: 1
categories:
- name: RemoteController
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
-- Copyright © 2025 SmartThings, Inc.
-- Licensed under the Apache License, Version 2.0

local clusters = require "st.matter.clusters"
local switch_utils = require "switch_utils.utils"
local scroll_utils = require "sub_drivers.ikea_scroll.scroll_utils.utils"
local scroll_cfg = require "sub_drivers.ikea_scroll.scroll_utils.device_configuration"
local event_handlers = require "sub_drivers.ikea_scroll.scroll_handlers.event_handlers"

local IkeaScrollLifecycleHandlers = {}

Expand Down Expand Up @@ -44,6 +46,14 @@ local ikea_scroll_handler = {
infoChanged = IkeaScrollLifecycleHandlers.info_changed,
init = IkeaScrollLifecycleHandlers.device_init,
},
matter_handlers = {
event = {
[clusters.Switch.ID] = {
[clusters.Switch.events.MultiPressOngoing.ID] = event_handlers.multi_press_ongoing_handler,
[clusters.Switch.events.MultiPressComplete.ID] = event_handlers.multi_press_complete_handler,
}
}
},
can_handle = require("sub_drivers.ikea_scroll.can_handle")
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
-- Copyright © 2025 SmartThings, Inc.
-- Licensed under the Apache License, Version 2.0

local st_utils = require "st.utils"
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 IkeaScrollEventHandlers = {}

local function rotate_amount_event_helper(device, endpoint_id, num_presses_to_handle)
-- 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}))
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)
end
end
end

function IkeaScrollEventHandlers.multi_press_complete_handler(driver, device, ib, response)
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)
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may have missed this during prior reviews, but I think we need to be looking at the TotalNumberOfPressesCounted field compared to CurrentNumberOfPressesCounted in any previously received MultiPressOngoing events.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That might be a nice check, though per spec and in all testing I've done, the final MultiPressOngoing event should be equivalent to the last MultiPressComplete event.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking it could report a different number, but on re-review of the spec I think you're correct that the values reported should be equivalent and since we're using the MultiPressOngoing we can safely ignore the MultiPressComplete here.

Copy link
Contributor

@tpmanley tpmanley Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You were fast! I think it's also fine to leave this change in now that you made it, shouldn't hurt anything.

end
end

function IkeaScrollEventHandlers.initial_press_handler(driver, device, ib, response)
if switch_utils.tbl_contains(scroll_fields.ENDPOINTS_PUSH, ib.endpoint_id) then
generic_event_handlers.initial_press_handler(driver, device, ib, response)
else
-- Ignore InitialPress events from non-push endpoints. Presently, we want to solely
-- rely on MultiPressOngoing events to handle rotation for those endpoints.
device.log.debug("Received InitialPress event from scroll endpoint, ignoring.")
end
end

return IkeaScrollEventHandlers
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,22 @@ local IkeaScrollConfiguration = {}

function IkeaScrollConfiguration.build_button_component_map(device)
local component_map = {
main = scroll_fields.ENDPOINTS_PRESS[1],
group2 = scroll_fields.ENDPOINTS_PRESS[2],
group3 = scroll_fields.ENDPOINTS_PRESS[3],
main = {scroll_fields.ENDPOINTS_PUSH[1], scroll_fields.ENDPOINTS_UP_SCROLL[1], scroll_fields.ENDPOINTS_DOWN_SCROLL[1]},
group2 = {scroll_fields.ENDPOINTS_PUSH[2], scroll_fields.ENDPOINTS_UP_SCROLL[2], scroll_fields.ENDPOINTS_DOWN_SCROLL[2]},
group3 = {scroll_fields.ENDPOINTS_PUSH[3], scroll_fields.ENDPOINTS_UP_SCROLL[3], scroll_fields.ENDPOINTS_DOWN_SCROLL[3]},
}
device:set_field(switch_fields.COMPONENT_TO_ENDPOINT_MAP, component_map, {persist = true})
end

function IkeaScrollConfiguration.configure_buttons(device)
for _, ep in ipairs(scroll_fields.ENDPOINTS_PRESS) do
for _, ep in ipairs(scroll_fields.ENDPOINTS_PUSH) do
device:send(clusters.Switch.attributes.MultiPressMax:read(device, ep))
switch_utils.set_field_for_endpoint(device, switch_fields.SUPPORTS_MULTI_PRESS, ep, true, {persist = true})
device:emit_event_for_endpoint(ep, capabilities.button.button.pushed({state_change = false}))
end
for _, ep in ipairs(scroll_fields.ENDPOINTS_UP_SCROLL) do -- and by extension, ENDPOINTS_DOWN_SCROLL
device:emit_event_for_endpoint(ep, capabilities.knob.supportedAttributes({"rotateAmount"}, {visibility = {displayed = false}}))
end
end

function IkeaScrollConfiguration.match_profile(driver, device)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,44 @@
-- Copyright © 2025 SmartThings, Inc.
-- Licensed under the Apache License, Version 2.0

local st_utils = require "st.utils"
local clusters = require "st.matter.clusters"

local IkeaScrollFields = {}

-- PowerSource supported on Root Node
IkeaScrollFields.ENDPOINT_POWER_SOURCE = 0

-- Switch Endpoints used for basic press functionality
IkeaScrollFields.ENDPOINTS_PRESS = {3, 6, 9}
-- Generic Switch Endpoints used for basic push functionality
IkeaScrollFields.ENDPOINTS_PUSH = {3, 6, 9}

-- Required Events for the ENDPOINTS_PRESS.
-- Generic Switch Endpoints used for Up Scroll functionality
IkeaScrollFields.ENDPOINTS_UP_SCROLL = {1, 4, 7}

-- Generic Switch Endpoints used for Down Scroll functionality
IkeaScrollFields.ENDPOINTS_DOWN_SCROLL = {2, 5, 8}

-- Maximum number of presses at a time
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)

-- 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"

-- Required Events for the ENDPOINTS_PUSH.
IkeaScrollFields.switch_press_subscribed_events = {
clusters.Switch.events.InitialPress.ID,
clusters.Switch.events.MultiPressComplete.ID,
clusters.Switch.events.LongPress.ID,
}

-- Required Events for the ENDPOINTS_UP_SCROLL and ENDPOINTS_DOWN_SCROLL. Adds a
-- MultiPressOngoing subscription to handle step functionality in real-time
IkeaScrollFields.switch_scroll_subscribed_events = {
clusters.Switch.events.MultiPressOngoing.ID,
clusters.Switch.events.MultiPressComplete.ID,
}

return IkeaScrollFields
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,24 @@ local scroll_fields = require "sub_drivers.ikea_scroll.scroll_utils.fields"

local IkeaScrollUtils = {}

-- override subscribe function to prevent subscribing to additional events from the main driver
-- override subscribe function in the main driver
function IkeaScrollUtils.subscribe(device)
local subscribe_request = im.InteractionRequest(im.InteractionRequest.RequestType.SUBSCRIBE, {})
for _, ep_press in ipairs(scroll_fields.ENDPOINTS_PRESS) do
for _, ep_push in ipairs(scroll_fields.ENDPOINTS_PUSH) do
for _, switch_event in ipairs(scroll_fields.switch_press_subscribed_events) do
local ib = im.InteractionInfoBlock(ep_press, clusters.Switch.ID, nil, switch_event)
local ib = im.InteractionInfoBlock(ep_push, clusters.Switch.ID, nil, switch_event)
subscribe_request:with_info_block(ib)
end
end
for _, ep_up in ipairs(scroll_fields.ENDPOINTS_UP_SCROLL) do
for _, switch_event in ipairs(scroll_fields.switch_scroll_subscribed_events) do
local ib = im.InteractionInfoBlock(ep_up, clusters.Switch.ID, nil, switch_event)
subscribe_request:with_info_block(ib)
end
end
for _, ep_down in ipairs(scroll_fields.ENDPOINTS_DOWN_SCROLL) do
for _, switch_event in ipairs(scroll_fields.switch_scroll_subscribed_events) do
local ib = im.InteractionInfoBlock(ep_down, clusters.Switch.ID, nil, switch_event)
subscribe_request:with_info_block(ib)
end
end
Expand Down
20 changes: 15 additions & 5 deletions drivers/SmartThings/matter-switch/src/switch_utils/utils.lua
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@ end

--- An extension of the library function endpoint_to_component, used to support a mapping scheme
--- that optionally includes cluster and attribute ids so that multiple components can be mapped
--- to a single endpoint.
--- to a single endpoint. This extension also handles the case that multiple endpoints map to the
--- same component
---
--- @param device any a Matter device object
--- @param ep_info number|table either an ep_id or a table { endpoint_id, optional(cluster_id), optional(attribute_id) }
Expand All @@ -220,10 +221,19 @@ function utils.endpoint_to_component(device, ep_info)
for component, map_info in pairs(device:get_field(fields.COMPONENT_TO_ENDPOINT_MAP) or {}) do
if type(map_info) == "number" and map_info == ep_info.endpoint_id then
return component
elseif type(map_info) == "table" and map_info.endpoint_id == ep_info.endpoint_id
and (not map_info.cluster_id or (map_info.cluster_id == ep_info.cluster_id
and (not map_info.attribute_ids or utils.tbl_contains(map_info.attribute_ids, ep_info.attribute_id)))) then
return component
elseif type(map_info) == "table" then
if type(map_info.endpoint_id) == "number" then
map_info = {map_info}
end
for _, ep_map_info in ipairs(map_info) do
if type(ep_map_info) == "number" and ep_map_info == ep_info.endpoint_id then
return component
elseif type(ep_map_info) == "table" and ep_map_info.endpoint_id == ep_info.endpoint_id
and (not ep_map_info.cluster_id or (ep_map_info.cluster_id == ep_info.cluster_id
and (not ep_map_info.attribute_ids or utils.tbl_contains(ep_map_info.attribute_ids, ep_info.attribute_id)))) then
return component
end
end
end
end
return "main"
Expand Down
Loading
Loading