diff --git a/drivers/SmartThings/matter-switch/profiles/ikea-scroll.yml b/drivers/SmartThings/matter-switch/profiles/ikea-scroll.yml new file mode 100644 index 0000000000..4dc77d026d --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/ikea-scroll.yml @@ -0,0 +1,29 @@ +name: ikea-scroll +components: + - id: main + label: Group 1 + capabilities: + - id: button + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: group2 + label: Group 2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: group3 + label: Group 3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/src/init.lua b/drivers/SmartThings/matter-switch/src/init.lua index ec3c5df9ba..c3c3a361cd 100644 --- a/drivers/SmartThings/matter-switch/src/init.lua +++ b/drivers/SmartThings/matter-switch/src/init.lua @@ -299,6 +299,7 @@ local matter_driver_template = { require("sub_drivers.aqara_cube"), switch_utils.lazy_load("sub_drivers.camera"), require("sub_drivers.eve_energy"), + require("sub_drivers.ikea_scroll"), require("sub_drivers.third_reality_mk1") } } diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/init.lua new file mode 100644 index 0000000000..bfdde071e7 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/init.lua @@ -0,0 +1,50 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +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 IkeaScrollLifecycleHandlers = {} + +-- prevent main driver device_added handling from running +function IkeaScrollLifecycleHandlers.device_added(driver, device) +end + +function IkeaScrollLifecycleHandlers.device_init(driver, device) + device:set_endpoint_to_component_fn(switch_utils.endpoint_to_component) + device:extend_device("subscribe", scroll_utils.subscribe) + device:subscribe() +end + +function IkeaScrollLifecycleHandlers.do_configure(driver, device) + scroll_cfg.match_profile(driver, device) +end + +function IkeaScrollLifecycleHandlers.driver_switched(driver, device) + scroll_cfg.match_profile(driver, device) +end + +function IkeaScrollLifecycleHandlers.info_changed(driver, device, event, args) + if device.profile.id ~= args.old_st_store.profile.id then + scroll_cfg.configure_buttons(device) + device:subscribe() + end +end + + +-- DEVICE TEMPLATE -- + +local ikea_scroll_handler = { + NAME = "Ikea Scroll Handler", + lifecycle_handlers = { + added = IkeaScrollLifecycleHandlers.device_added, + doConfigure = IkeaScrollLifecycleHandlers.do_configure, + driverSwitched = IkeaScrollLifecycleHandlers.driver_switched, + infoChanged = IkeaScrollLifecycleHandlers.info_changed, + init = IkeaScrollLifecycleHandlers.device_init, + }, + can_handle = scroll_utils.is_ikea_scroll +} + +return ikea_scroll_handler diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/device_configuration.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/device_configuration.lua new file mode 100644 index 0000000000..cd2cee49ce --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/device_configuration.lua @@ -0,0 +1,35 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.matter.clusters" +local capabilities = require "st.capabilities" +local switch_utils = require "switch_utils.utils" +local switch_fields = require "switch_utils.fields" +local scroll_fields = require "sub_drivers.ikea_scroll.scroll_utils.fields" + +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], + } + 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 + 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 +end + +function IkeaScrollConfiguration.match_profile(driver, device) + device:try_update_metadata({profile = "ikea-scroll"}) + IkeaScrollConfiguration.build_button_component_map(device) + IkeaScrollConfiguration.configure_buttons(device) +end + +return IkeaScrollConfiguration 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 new file mode 100644 index 0000000000..b31777ec20 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/fields.lua @@ -0,0 +1,20 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +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} + +-- Required Events for the ENDPOINTS_PRESS. Ignore InitialPress since this slows handling. +IkeaScrollFields.switch_press_subscribed_events = { + clusters.Switch.events.MultiPressComplete.ID, + clusters.Switch.events.LongPress.ID, +} + +return IkeaScrollFields diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/utils.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/utils.lua new file mode 100644 index 0000000000..9be4d8601d --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/ikea_scroll/scroll_utils/utils.lua @@ -0,0 +1,31 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local im = require "st.matter.interaction_model" +local clusters = require "st.matter.clusters" +local switch_utils = require "switch_utils.utils" +local scroll_fields = require "sub_drivers.ikea_scroll.scroll_utils.fields" + +local IkeaScrollUtils = {} + +function IkeaScrollUtils.is_ikea_scroll(opts, driver, device) + return switch_utils.get_product_override_field(device, "is_ikea_scroll") +end + +-- override subscribe function to prevent subscribing to additional events from 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 _, switch_event in ipairs(scroll_fields.switch_press_subscribed_events) do + local ib = im.InteractionInfoBlock(ep_press, clusters.Switch.ID, nil, switch_event) + subscribe_request:with_info_block(ib) + end + end + local ib = im.InteractionInfoBlock( + scroll_fields.ENDPOINT_POWER_SOURCE, clusters.PowerSource.ID, clusters.PowerSource.attributes.BatPercentRemaining.ID + ) + subscribe_request:with_info_block(ib) + device:send(subscribe_request) +end + +return IkeaScrollUtils \ No newline at end of file diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua index 3a0bdc9fdb..33138fb922 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua @@ -117,7 +117,10 @@ SwitchFields.vendor_overrides = { [0x1006] = { ignore_combo_switch_button = true, target_profile = "light-level-power-energy-powerConsumption", ep_id = 1 }, -- 3 Buttons(Generic Switch), 1 Channels(Dimmable Light) [0x100A] = { ignore_combo_switch_button = true, target_profile = "light-level-power-energy-powerConsumption", ep_id = 1 }, -- 1 Buttons(Generic Switch), 1 Channels(Dimmable Light) [0x2004] = { is_climate_sensor_w100 = true }, -- Climate Sensor W100, requires unique profile - } + }, + [0x117C] = { -- IKEA_MANUFACTURER_ID + [0x8000] = { is_ikea_scroll = true } + }, } SwitchFields.switch_category_vendor_overrides = { diff --git a/drivers/SmartThings/matter-switch/src/test/test_ikea_scroll.lua b/drivers/SmartThings/matter-switch/src/test/test_ikea_scroll.lua new file mode 100644 index 0000000000..96e5a8693e --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_ikea_scroll.lua @@ -0,0 +1,223 @@ +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" + +local mock_ikea_scroll = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("ikea-scroll.yml"), + manufacturer_info = {vendor_id = 0x117C, product_id = 0x8000, product_name = "Ikea Scroll"}, + label = "Ikea Scroll", + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" }, + }, + device_types = { + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode + } + }, + { + endpoint_id = 1, + clusters = {{ + cluster_id = clusters.Switch.ID, + feature_map = + clusters.Switch.types.Feature.MOMENTARY_SWITCH | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS, + cluster_type = "SERVER" + },}, + device_types = {{device_type_id = 0x000F, device_type_revision = 1}} -- GENERIC SWITCH + }, + { + endpoint_id = 2, + clusters = {{ + cluster_id = clusters.Switch.ID, + feature_map = + clusters.Switch.types.Feature.MOMENTARY_SWITCH | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS, + cluster_type = "SERVER" + },}, + device_types = {{device_type_id = 0x000F, device_type_revision = 1}} -- GENERIC SWITCH + }, + { + endpoint_id = 3, + clusters = {{ + cluster_id = clusters.Switch.ID, + feature_map = + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS, + cluster_type = "SERVER"}, + }, + device_types = {{device_type_id = 0x000F, device_type_revision = 1}} -- GENERIC SWITCH + }, + { + endpoint_id = 4, + clusters = {{ + cluster_id = clusters.Switch.ID, + feature_map = + clusters.Switch.types.Feature.MOMENTARY_SWITCH | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS, + cluster_type = "SERVER" + },}, + device_types = {{device_type_id = 0x000F, device_type_revision = 1}} -- GENERIC SWITCH + }, + { + endpoint_id = 5, + clusters = {{ + cluster_id = clusters.Switch.ID, + feature_map = + clusters.Switch.types.Feature.MOMENTARY_SWITCH | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS, + cluster_type = "SERVER" + },}, + device_types = {{device_type_id = 0x000F, device_type_revision = 1}} -- GENERIC SWITCH + }, + { + endpoint_id = 6, + clusters = {{ + cluster_id = clusters.Switch.ID, + feature_map = + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS, + cluster_type = "SERVER"}, + }, + device_types = {{device_type_id = 0x000F, device_type_revision = 1}} -- GENERIC SWITCH + }, + { + endpoint_id = 7, + clusters = {{ + cluster_id = clusters.Switch.ID, + feature_map = + clusters.Switch.types.Feature.MOMENTARY_SWITCH | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS, + cluster_type = "SERVER" + },}, + device_types = {{device_type_id = 0x000F, device_type_revision = 1}} -- GENERIC SWITCH + }, + { + endpoint_id = 8, + clusters = {{ + cluster_id = clusters.Switch.ID, + feature_map = + clusters.Switch.types.Feature.MOMENTARY_SWITCH | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS, + cluster_type = "SERVER" + },}, + device_types = {{device_type_id = 0x000F, device_type_revision = 1}} -- GENERIC SWITCH + }, + { + endpoint_id = 9, + clusters = {{ + cluster_id = clusters.Switch.ID, + feature_map = + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS, + cluster_type = "SERVER"}, + }, + device_types = {{device_type_id = 0x000F, device_type_revision = 1}} -- GENERIC SWITCH + }, + } +}) + +local ENDPOINTS_PRESS = { 3, 6, 9 } + +-- the ikea scroll subdriver has overriden subscribe behavior +local function ikea_scroll_subscribe() + local CLUSTER_SUBSCRIBE_LIST ={ + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.MultiPressComplete, + } + local subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_ikea_scroll, ENDPOINTS_PRESS[1]) + for _, ep_press in ipairs(ENDPOINTS_PRESS) do + for _, event in ipairs(CLUSTER_SUBSCRIBE_LIST) do + subscribe_request:merge(event:subscribe(mock_ikea_scroll, ep_press)) + end + end + subscribe_request:merge(clusters.PowerSource.attributes.BatPercentRemaining:subscribe(mock_ikea_scroll, 0)) + return subscribe_request +end + +local function expect_configure_buttons() + local button_attr = capabilities.button.button + test.socket.matter:__expect_send({mock_ikea_scroll.id, clusters.Switch.attributes.MultiPressMax:read(mock_ikea_scroll, 3)}) + test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("main", button_attr.pushed({state_change = false}))) + test.socket.matter:__expect_send({mock_ikea_scroll.id, clusters.Switch.attributes.MultiPressMax:read(mock_ikea_scroll, 6)}) + test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("group2", button_attr.pushed({state_change = false}))) + test.socket.matter:__expect_send({mock_ikea_scroll.id, clusters.Switch.attributes.MultiPressMax:read(mock_ikea_scroll, 9)}) + test.socket.capability:__expect_send(mock_ikea_scroll:generate_test_message("group3", button_attr.pushed({state_change = false}))) +end + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_ikea_scroll) + local subscribe_request = ikea_scroll_subscribe() + + test.socket.device_lifecycle:__queue_receive({ mock_ikea_scroll.id, "added" }) + + test.socket.device_lifecycle:__queue_receive({ mock_ikea_scroll.id, "init" }) + test.socket.matter:__expect_send({mock_ikea_scroll.id, subscribe_request}) + + mock_ikea_scroll:expect_metadata_update({ profile = "ikea-scroll" }) + mock_ikea_scroll:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + expect_configure_buttons() + test.socket.device_lifecycle:__queue_receive({ mock_ikea_scroll.id, "doConfigure" }) +end +test.set_test_init_function(test_init) + +test.register_message_test( + "Ensure Ikea Scroll Button initialization works as expected", { + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.attributes.MultiPressMax:build_test_report_data( + mock_ikea_scroll, 3, 3 + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("main", + capabilities.button.supportedButtonValues({"pushed", "double", "held", "pushed_3x"}, {visibility = {displayed = false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.attributes.MultiPressMax:build_test_report_data( + mock_ikea_scroll, 6, 3 + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("group2", + capabilities.button.supportedButtonValues({"pushed", "double", "held", "pushed_3x"}, {visibility = {displayed = false}})) + }, + { + channel = "matter", + direction = "receive", + message = { + mock_ikea_scroll.id, + clusters.Switch.attributes.MultiPressMax:build_test_report_data( + mock_ikea_scroll, 9, 3 + ) + }, + }, + { + channel = "capability", + direction = "send", + message = mock_ikea_scroll:generate_test_message("group3", + capabilities.button.supportedButtonValues({"pushed", "double", "held", "pushed_3x"}, {visibility = {displayed = false}})) + }, + } +) + +test.run_registered_tests() \ No newline at end of file