From d1f994da50d728edd9cb9206a2b63f129d6bfccb Mon Sep 17 00:00:00 2001 From: Nick DeBoom Date: Thu, 1 May 2025 10:18:26 -0500 Subject: [PATCH] Matter Switch: Add subdriver for Third Reality MK1 Add new profile and a subdriver to support the Third Reality keyboard, which contains 12 matter-enabled buttons. --- .../profiles/12-button-keyboard.yml | 78 ++++++ .../SmartThings/matter-switch/src/init.lua | 7 +- .../src/test/test_third_reality_mk1.lua | 243 ++++++++++++++++++ .../src/third-reality-mk1/init.lua | 138 ++++++++++ 4 files changed, 463 insertions(+), 3 deletions(-) create mode 100644 drivers/SmartThings/matter-switch/profiles/12-button-keyboard.yml create mode 100644 drivers/SmartThings/matter-switch/src/test/test_third_reality_mk1.lua create mode 100644 drivers/SmartThings/matter-switch/src/third-reality-mk1/init.lua diff --git a/drivers/SmartThings/matter-switch/profiles/12-button-keyboard.yml b/drivers/SmartThings/matter-switch/profiles/12-button-keyboard.yml new file mode 100644 index 0000000000..c1278b25f5 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/12-button-keyboard.yml @@ -0,0 +1,78 @@ +name: 12-button-keyboard +components: + - id: F1 + capabilities: + - id: button + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: F2 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: F3 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: F4 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: F5 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: F6 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: F7 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: F8 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: F9 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: F10 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: F11 + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController + - id: F12 + 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 6c1714b97b..8d99e62ccb 100644 --- a/drivers/SmartThings/matter-switch/src/init.lua +++ b/drivers/SmartThings/matter-switch/src/init.lua @@ -703,8 +703,8 @@ local function device_init(driver, device) end for _, attr in pairs(device_type_attribute_map[id] or {}) do if id == GENERIC_SWITCH_ID and - attr ~= clusters.PowerSource.attributes.BatPercentRemaining and - attr ~= clusters.PowerSource.attributes.BatChargeLevel then + attr ~= clusters.PowerSource.attributes.BatPercentRemaining and + attr ~= clusters.PowerSource.attributes.BatChargeLevel then device:add_subscribed_event(attr) else device:add_subscribed_attribute(attr) @@ -1641,7 +1641,8 @@ local matter_driver_template = { }, sub_drivers = { require("eve-energy"), - require("aqara-cube") + require("aqara-cube"), + require("third-reality-mk1") } } diff --git a/drivers/SmartThings/matter-switch/src/test/test_third_reality_mk1.lua b/drivers/SmartThings/matter-switch/src/test/test_third_reality_mk1.lua new file mode 100644 index 0000000000..03c0e7ee74 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_third_reality_mk1.lua @@ -0,0 +1,243 @@ +-- 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. +-- 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 dkjson = require "dkjson" +local t_utils = require "integration_test.utils" +local test = require "integration_test" +local utils = require "st.utils" + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("12-button-keyboard.yml"), + manufacturer_info = {vendor_id = 0x1407, product_id = 0x1388}, + 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, + 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, + 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.Feature.MOMENTARY_SWITCH, + 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, + 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, + 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.Feature.MOMENTARY_SWITCH, + 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, + 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, + 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.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 10, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 11, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + }, + { + endpoint_id = 12, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} -- Generic Switch + } + } + } +}) + +local function configure_buttons() + for key = 1, 12 do + local component = "F" .. key + test.socket.capability:__expect_send(mock_device:generate_test_message(component, capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}))) + test.socket.capability:__expect_send(mock_device:generate_test_message(component, capabilities.button.button.pushed({state_change = false}))) + end +end + +local function test_init() + local cluster_subscribe_list = { + clusters.Switch.events.InitialPress + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) + for i, clus in ipairs(cluster_subscribe_list) do + if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end + end + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ profile = "12-button-keyboard" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + configure_buttons() + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + local device_info_copy = utils.deep_copy(mock_device.raw_st_data) + device_info_copy.profile.id = "12-buttons-keyboard" + local device_info_json = dkjson.encode(device_info_copy) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "infoChanged", device_info_json }) + configure_buttons() + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Handle single press sequence", + function() + for key = 1, 12 do + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report(mock_device, key, {new_position = 1}) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("F" .. key, capabilities.button.button.pushed({state_change = true})) + ) + end + end +) + +-- run the tests +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/third-reality-mk1/init.lua b/drivers/SmartThings/matter-switch/src/third-reality-mk1/init.lua new file mode 100644 index 0000000000..89f76cc903 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/third-reality-mk1/init.lua @@ -0,0 +1,138 @@ +-- 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. +-- 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 im = require "st.matter.interaction_model" +local log = require "log" + +local COMPONENT_TO_ENDPOINT_MAP = "__component_to_endpoint_map" + +------------------------------------------------------------------------------------- +-- Third Reality MK1 specifics +------------------------------------------------------------------------------------- + +local THIRD_REALITY_MK1_FINGERPRINT = { vendor_id = 0x1407, product_id = 0x1388 } + +local function is_third_reality_mk1(opts, driver, device) + if device.network_type == device_lib.NETWORK_TYPE_MATTER and + device.manufacturer_info.vendor_id == THIRD_REALITY_MK1_FINGERPRINT.vendor_id and + device.manufacturer_info.product_id == THIRD_REALITY_MK1_FINGERPRINT.product_id then + log.info("Using Third Reality MK1 sub driver") + return true + end + return false +end + +local function endpoint_to_component(device, ep) + local map = device:get_field(COMPONENT_TO_ENDPOINT_MAP) or {} + for component, endpoint in pairs(map) do + if endpoint == ep then + return component + end + end + return "F1" +end + +-- override subscribe function to prevent subscribing to additional events from the main driver +local function subscribe(device) + local ib = im.InteractionInfoBlock(nil, clusters.Switch.ID, nil, clusters.Switch.events.InitialPress.ID) + local subscribe_request = im.InteractionRequest(im.InteractionRequest.RequestType.SUBSCRIBE, {}) + subscribe_request:with_info_block(ib) + device:send(subscribe_request) +end + +local function configure_buttons(device) + local ms_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) + for _, ep in ipairs(ms_eps) do + if device.profile.components[endpoint_to_component(device, ep)] then + device.log.info_with({hub_logs=true}, string.format("Configuring Supported Values for generic switch endpoint %d", ep)) + local supportedButtonValues_event = capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}) + device:emit_event_for_endpoint(ep, supportedButtonValues_event) + device:emit_event_for_endpoint(ep, capabilities.button.button.pushed({state_change = false})) + else + device.log.info_with({hub_logs=true}, string.format("Component not found for generic switch endpoint %d. Skipping Supported Value configuration", ep)) + end + end +end + +local function build_button_component_map(device) + local button_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) + table.sort(button_eps) + local component_map = {} + for component_num, ep in ipairs(button_eps) do + local button_component = "F" .. component_num + component_map[button_component] = ep + end + device:set_field(COMPONENT_TO_ENDPOINT_MAP, component_map, {persist = true}) +end + +local function device_init(driver, device) + device:set_endpoint_to_component_fn(endpoint_to_component) + device:extend_device("subscribe", subscribe) + device:subscribe() +end + +-- override device_added to prevent it running in the main driver +local function device_added(driver, device) end + +local function info_changed(driver, device, event, args) + if device.profile.id ~= args.old_st_store.profile.id then + configure_buttons(device) + device:subscribe() + end +end + +local function match_profile(driver, device) + device:try_update_metadata({profile = "12-button-keyboard"}) + build_button_component_map(device) + configure_buttons(device) +end + +local function do_configure(driver, device) + match_profile(driver, device) +end + +local function driver_switched(driver, device) + match_profile(driver, device) +end + +local function initial_press_event_handler(driver, device, ib, response) + device:emit_event_for_endpoint(ib.endpoint_id, capabilities.button.button.pushed({state_change = true})) +end + +local third_reality_mk1_handler = { + NAME = "ThirdReality MK1 Handler", + lifecycle_handlers = { + init = device_init, + added = device_added, + infoChanged = info_changed, + doConfigure = do_configure, + driverSwitched = driver_switched + }, + matter_handlers = { + event = { + [clusters.Switch.ID] = { + [clusters.Switch.events.InitialPress.ID] = initial_press_event_handler + } + } + }, + supported_capabilities = { + capabilities.button + }, + can_handle = is_third_reality_mk1 +} + +return third_reality_mk1_handler