diff --git a/drivers/SmartThings/matter-switch/capabilities/cubeAction.yml b/drivers/SmartThings/matter-switch/capabilities/cubeAction.yml new file mode 100644 index 0000000000..fa2890e992 --- /dev/null +++ b/drivers/SmartThings/matter-switch/capabilities/cubeAction.yml @@ -0,0 +1,29 @@ +id: stse.cubeAction +version: 1 +status: proposed +name: Cube Action +ephemeral: false +attributes: + cubeAction: + schema: + type: object + properties: + value: + title: ActionType + type: string + enum: + - noAction + - shake + - rotate + - pickUpAndHold + - flipToSide1 + - flipToSide2 + - flipToSide3 + - flipToSide4 + - flipToSide5 + - flipToSide6 + additionalProperties: false + required: + - value + enumCommands: [] +commands: {} diff --git a/drivers/SmartThings/matter-switch/capabilities/cubeFace.yml b/drivers/SmartThings/matter-switch/capabilities/cubeFace.yml new file mode 100644 index 0000000000..af7e7301f8 --- /dev/null +++ b/drivers/SmartThings/matter-switch/capabilities/cubeFace.yml @@ -0,0 +1,25 @@ +id: stse.cubeFace +version: 1 +status: proposed +name: Cube Face +ephemeral: false +attributes: + cubeFace: + schema: + type: object + properties: + value: + title: CubeFace + type: string + enum: + - face1Up + - face2Up + - face3Up + - face4Up + - face5Up + - face6Up + additionalProperties: false + required: + - value + enumCommands: [] +commands: {} diff --git a/drivers/SmartThings/matter-switch/profiles/cube-t1-pro.yml b/drivers/SmartThings/matter-switch/profiles/cube-t1-pro.yml new file mode 100644 index 0000000000..1558c9cd11 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/cube-t1-pro.yml @@ -0,0 +1,22 @@ +name: cube-t1-pro +components: + - id: main + capabilities: + - id: stse.cubeAction + version: 1 + - id: stse.cubeFace + version: 1 + - id: button + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController +metadata: + mnmn: SolutionsEngineering + vid: SmartThings-smartthings-Aqara_CubeT1Pro + diff --git a/drivers/SmartThings/matter-switch/src/aqara-cube/init.lua b/drivers/SmartThings/matter-switch/src/aqara-cube/init.lua new file mode 100644 index 0000000000..b377b40640 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/aqara-cube/init.lua @@ -0,0 +1,225 @@ +local capabilities = require "st.capabilities" +local clusters = require "st.matter.clusters" +local device_lib = require "st.device" + +local cubeAction = capabilities["stse.cubeAction"] +local cubeFace = capabilities["stse.cubeFace"] + +local COMPONENT_TO_ENDPOINT_MAP_BUTTON = "__component_to_endpoint_map_button" +local DEFERRED_CONFIGURE = "__DEFERRED_CONFIGURE" +local DOING_CONFIGURE = "__DOING_CONFIGURE" +local INITIAL_PRESS_ONLY = "__initial_press_only" -- for devices that support MS (MomentarySwitch), but not MSR (MomentarySwitchRelease) + +-- after 3 seconds of cubeAction, to automatically change the action status of Plugin UI or Device Card to noAction +local CUBEACTION_TIMER = "__cubeAction_timer" +local CUBEACTION_TIME = 3 + +local function is_aqara_cube(opts, driver, device) + local name = string.format("%s", device.label) + if device.network_type == device_lib.NETWORK_TYPE_MATTER and + string.find(name, "Aqara Cube T1 Pro") then + return true + end + return false +end + +local callback_timer = function(device) + return function() + device:emit_event(cubeAction.cubeAction("noAction")) + end +end + +local function reset_thread(device) + local timer = device:get_field(CUBEACTION_TIMER) + if timer then + device.thread:cancel_timer(timer) + device:set_field(CUBEACTION_TIMER, nil) + end + device:set_field(CUBEACTION_TIMER, device.thread:call_with_delay(CUBEACTION_TIME, callback_timer(device))) +end + +local function get_field_for_endpoint(device, field, endpoint) + return device:get_field(string.format("%s_%d", field, endpoint)) +end + +local function set_field_for_endpoint(device, field, endpoint, value, persist) + device:set_field(string.format("%s_%d", field, endpoint), value, {persist = persist}) +end + +-- The endpoints of each face may increase sequentially, but may increase as in [250, 251, 2, 3, 4, 5] +-- and the current device:get_endpoints function is valid only for the former so, adds this function. +local function get_reordered_endpoints(driver, device) + if device.network_type ~= device_lib.NETWORK_TYPE_CHILD then + local MS = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) + -- find the default/main endpoint, the device with the lowest EP that supports MS + table.sort(MS) + if MS[6] < (MS[1] + 150) then + -- When the endpoints of each face increase sequentially + -- The lowest EP is the main endpoint + -- as a workaround, it is assumed that the first endpoint number and the last endpoint number are not larger than 150. + return MS + else + -- When the endpoints of each face do not increase sequentially... [250, 251, 2, 3, 4, 5] 250 is the main endpoint. + -- For the situation where a node following these mechanisms has exhausted all available 65535 endpoint addresses for exposed entities, + -- it MAY wrap around to the lowest unused endpoint address (refter to Matter Core Spec 9.2.4. Dynamic Endpoint Allocation) + local ept1 = {} -- First consecutive end points + local ept2 = {} -- Second consecutive end points + local idx1 = 1 + local idx2 = 1 + local flag = 0 + local previous = 0 + for _, ep in ipairs(MS) do + if idx1 == 1 then + ept1[idx1] = ep + else + if flag == 0 + and ep <= (previous + 15) then + -- the endpoint number does not always increase by 1 + -- as a workaround, assume that the next endpoint number is not greater than 15 + ept1[idx1] = ep + else + ept2[idx2] = ep + idx2 = idx2 + 1 + if flag ~= 1 then + flag = 1 + end + end + end + idx1 = idx1 + 1 + previous = ep + end + + local start = #ept2 + 1 + idx1 = 1 + idx2 = start + for i=start, 6 do + ept2[idx2] = ept1[idx1] + idx1 = idx1 + 1 + idx2 = idx2 + 1 + end + return ept2 + end + end +end + +local function endpoint_to_component(device, endpoint) + return "main" +end + +local function device_init(driver, device) + if device.network_type == device_lib.NETWORK_TYPE_MATTER then + device:subscribe() + device:set_endpoint_to_component_fn(endpoint_to_component) + end +end + +-- This is called either on add for parent/child devices, or after the device profile changes for components +local function configure_buttons(device) + if device.network_type ~= device_lib.NETWORK_TYPE_CHILD then + local MS = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) + device.log.debug(#MS.." momentary switch endpoints") + for _, ep in ipairs(MS) do + -- device only supports momentary switch, no release events + device.log.debug("configuring for press event only") + set_field_for_endpoint(device, INITIAL_PRESS_ONLY, ep, true, true) + end + end +end + +local function device_added(driver, device) + if device.network_type ~= device_lib.NETWORK_TYPE_CHILD then + local MS = get_reordered_endpoints(driver, device) + local main_endpoint = device.MATTER_DEFAULT_ENDPOINT + if #MS > 0 then + main_endpoint = MS[1] -- the endpoint matching to the non-child device + if MS[1] == 0 then main_endpoint = MS[2] end -- we shouldn't hit this, but just in case + end + device.log.debug("main button endpoint is "..main_endpoint) + + -- At the moment, we're taking it for granted that all momentary switches only have 2 positions + local current_component_number = 1 + local component_map = {} + for _, ep in ipairs(MS) do -- for each momentary switch endpoint (including main) + device.log.debug("Configuring endpoint "..ep) + -- build the mapping of endpoints to components + component_map[string.format("%d", current_component_number)] = ep + current_component_number = current_component_number + 1 + end + + device:set_field(COMPONENT_TO_ENDPOINT_MAP_BUTTON, component_map, {persist = true}) + device:try_update_metadata({profile = "cube-t1-pro"}) + device:set_field(DEFERRED_CONFIGURE, true) + end +end + +local function info_changed(driver, device, event, args) + if device.profile.id ~= args.old_st_store.profile.id + and device:get_field(DEFERRED_CONFIGURE) + and device.network_type ~= device_lib.NETWORK_TYPE_CHILD then + + -- At the time of device_added, there is an error that the corresponding capability cannot be found + -- because the profile has not been changed from the generic profile of fingerprint to the Aqara Cube. + reset_thread(device) + device:emit_event(cubeFace.cubeFace("face1Up")) + + device:set_field(DEFERRED_CONFIGURE, nil) + + -- In the current test framework, the values of device.profile.id and args.old_st_store.profile.id are always the same, + -- so the test coverage cannot be increased. This is why I added DOING_CONFIGURE flag. + device:set_field(DOING_CONFIGURE, true) + end + + if device:get_field(DOING_CONFIGURE) then + -- profile has changed, and we deferred setting up our buttons, so do that now + configure_buttons(device) + device:set_field(DOING_CONFIGURE, nil) + end +end + +local function initial_press_event_handler(driver, device, ib, response) + if get_field_for_endpoint(device, INITIAL_PRESS_ONLY, ib.endpoint_id) then + local map = device:get_field(COMPONENT_TO_ENDPOINT_MAP_BUTTON) or {} + local face = 1 + for component, ep in pairs(map) do + if map[component] == ib.endpoint_id then + face = component + break + end + end + + reset_thread(device) + device:emit_event(cubeAction.cubeAction(string.format("flipToSide%d", face))) + device:emit_event(cubeFace.cubeFace(string.format("face%dUp", face))) + end +end + +local function battery_percent_remaining_attr_handler(driver, device, ib, response) + if ib.data.value then + device:emit_event(capabilities.battery.battery(math.floor(ib.data.value / 2.0 + 0.5))) + end +end + +local aqara_cube_handler = { + NAME = "Aqara Cube Handler", + lifecycle_handlers = { + init = device_init, + added = device_added, + infoChanged = info_changed + }, + matter_handlers = { + attr = { + [clusters.PowerSource.ID] = { + [clusters.PowerSource.attributes.BatPercentRemaining.ID] = battery_percent_remaining_attr_handler + } + }, + event = { + [clusters.Switch.ID] = { + [clusters.Switch.events.InitialPress.ID] = initial_press_event_handler + } + }, + }, + can_handle = is_aqara_cube +} + +return aqara_cube_handler + diff --git a/drivers/SmartThings/matter-switch/src/init.lua b/drivers/SmartThings/matter-switch/src/init.lua index 9c6da7f50f..e3f4bf2247 100644 --- a/drivers/SmartThings/matter-switch/src/init.lua +++ b/drivers/SmartThings/matter-switch/src/init.lua @@ -1229,6 +1229,7 @@ local matter_driver_template = { }, sub_drivers = { require("eve-energy"), + require("aqara-cube") } } diff --git a/drivers/SmartThings/matter-switch/src/test/test_aqara_cube.lua b/drivers/SmartThings/matter-switch/src/test/test_aqara_cube.lua new file mode 100644 index 0000000000..069f30df3a --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_aqara_cube.lua @@ -0,0 +1,242 @@ +local test = require "integration_test" +test.add_package_capability("cubeAction.yml") +test.add_package_capability("cubeFace.yml") +local capabilities = require "st.capabilities" +local cubeAction = capabilities["stse.cubeAction"] +local cubeFace = capabilities["stse.cubeFace"] + +local t_utils = require "integration_test.utils" +local clusters = require "st.matter.clusters" + +local DOING_CONFIGURE = "__DOING_CONFIGURE" + +--mock the actual device +local mock_device = test.mock_device.build_test_matter_device( + { + profile = t_utils.get_profile_definition("cube-t1-pro.yml"), + manufacturer_info = {vendor_id = 0x115f, product_id = 0x0000}, + label = "Aqara Cube T1 Pro", + device_id = "00000000-1111-2222-3333-000000000001", + 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 = 2, + clusters = { + {cluster_id = clusters.Switch.ID, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, cluster_type = "SERVER"}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = clusters.PowerSource.types.PowerSourceFeature.BATTERY} + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} + }, + }, + { + endpoint_id = 3, + clusters = { + {cluster_id = clusters.Switch.ID, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, cluster_type = "SERVER"}, + }, + }, + { + endpoint_id = 4, + clusters = { + {cluster_id = clusters.Switch.ID, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, cluster_type = "SERVER"}, + }, + }, + { + endpoint_id = 5, + clusters = { + {cluster_id = clusters.Switch.ID, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, cluster_type = "SERVER"}, + }, + }, + { + endpoint_id = 6, + clusters = { + {cluster_id = clusters.Switch.ID, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, cluster_type = "SERVER"}, + }, + }, + { + endpoint_id = 7, + clusters = { + {cluster_id = clusters.Switch.ID, feature_map = clusters.Switch.types.Feature.MOMENTARY_SWITCH, cluster_type = "SERVER"}, + }, + }, + } + } +) + +local mock_exhausted_endpoint_device = test.mock_device.build_test_matter_device( + { + profile = t_utils.get_profile_definition("cube-t1-pro.yml"), + manufacturer_info = {vendor_id = 0x115f, product_id = 0x0000}, + label = "Aqara Cube T1 Pro", + device_id = "00000000-1111-2222-3333-000000000003", + endpoints = + { + { + endpoint_id = 2, + clusters = { + {cluster_id = clusters.Switch.ID, feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, cluster_type = "SERVER"}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER", feature_map = clusters.PowerSource.types.PowerSourceFeature.BATTERY} + }, + device_types = { + {device_type_id = 0x000F, device_type_revision = 1} + }, + }, + { + endpoint_id = 3, + clusters = { + {cluster_id = clusters.Switch.ID, feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, cluster_type = "SERVER"}, + }, + }, + { + endpoint_id = 4, + clusters = { + {cluster_id = clusters.Switch.ID, feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, cluster_type = "SERVER"}, + }, + }, + { + endpoint_id = 5, + clusters = { + {cluster_id = clusters.Switch.ID, feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, cluster_type = "SERVER"}, + }, + }, + { + endpoint_id = 250, + clusters = { + {cluster_id = clusters.Switch.ID, feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, cluster_type = "SERVER"}, + }, + }, + { + endpoint_id = 251, + clusters = { + {cluster_id = clusters.Switch.ID, feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH, cluster_type = "SERVER"}, + }, + }, + } + } +) + +-- add device for each mock device +local CLUSTER_SUBSCRIBE_LIST ={ + clusters.PowerSource.server.attributes.BatPercentRemaining, + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, +} + +local function test_init() + test.socket.matter:__set_channel_ordering("relaxed") + test.socket.capability:__set_channel_ordering("relaxed") + 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.matter:__expect_send({mock_device.id, subscribe_request}) + test.mock_device.add_test_device(mock_device) + + subscribe_request = CLUSTER_SUBSCRIBE_LIST[1]:subscribe(mock_exhausted_endpoint_device) + for i, cluster in ipairs(CLUSTER_SUBSCRIBE_LIST) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_exhausted_endpoint_device)) + end + end + test.socket.matter:__expect_send({mock_exhausted_endpoint_device.id, subscribe_request}) + test.mock_device.add_test_device(mock_exhausted_endpoint_device) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Handle single press sequence when changing the device_lifecycle", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.mock_devices_api._expected_device_updates[mock_device.device_id] = "00000000-1111-2222-3333-000000000001" + test.mock_devices_api._expected_device_updates[1] = {device_id = "00000000-1111-2222-3333-000000000001"} + test.mock_devices_api._expected_device_updates[1].metadata = {deviceId="00000000-1111-2222-3333-000000000001", profileReference="cube-t1-pro"} + + mock_device:set_field(DOING_CONFIGURE, true) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({value = "face1Up"})) + -- let the driver run + test.wait_for_events() + + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_device, 2, {new_position = 1} --move to position 1? + ) + } + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", cubeAction.cubeAction({value = "flipToSide1"})) + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", cubeFace.cubeFace({value = "face1Up"})) + ) + + test.socket.matter:__queue_receive( + { + mock_device.id, + clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data( + mock_device, 2, 150 + ) + } + ) + + test.socket.capability:__expect_send( + mock_device:generate_test_message( + "main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5)) + ) + ) + end +) + +test.register_coroutine_test( + "Handle single press sequence in case of exhausted endpoint", + function() + test.socket.device_lifecycle:__queue_receive({ mock_exhausted_endpoint_device.id, "added" }) + test.mock_devices_api._expected_device_updates[mock_exhausted_endpoint_device.device_id] = "00000000-1111-2222-3333-000000000003" + test.mock_devices_api._expected_device_updates[1] = {device_id = "00000000-1111-2222-3333-000000000003"} + test.mock_devices_api._expected_device_updates[1].metadata = {deviceId="00000000-1111-2222-3333-000000000003", profileReference="cube-t1-pro"} + + test.socket.matter:__queue_receive( + { + mock_exhausted_endpoint_device.id, + clusters.Switch.events.InitialPress:build_test_event_report( + mock_exhausted_endpoint_device, 2, {new_position = 1} --move to position 1? + ) + } + ) + + test.socket.matter:__queue_receive( + { + mock_exhausted_endpoint_device.id, + clusters.PowerSource.attributes.BatPercentRemaining:build_test_report_data( + mock_exhausted_endpoint_device, 2, 150 + ) + } + ) + + test.socket.capability:__expect_send( + mock_exhausted_endpoint_device:generate_test_message( + "main", capabilities.battery.battery(math.floor(150 / 2.0 + 0.5)) + ) + ) + end +) + +-- run the tests +test.run_registered_tests()