diff --git a/drivers/SmartThings/matter-switch/profiles/button-modular.yml b/drivers/SmartThings/matter-switch/profiles/button-modular.yml new file mode 100644 index 0000000000..8b259379ac --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/button-modular.yml @@ -0,0 +1,231 @@ +name: button-modular +components: + - id: main + capabilities: + - id: button + version: 1 + - id: battery + version: 1 + optional: true + - id: batteryLevel + version: 1 + optional: true + - id: motionSensor + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: RemoteController + - id: button1 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button2 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button3 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button4 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button5 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button6 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button7 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button8 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button9 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button10 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button11 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button12 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button13 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button14 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button15 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button16 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button17 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button18 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button19 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button20 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button21 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button22 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button23 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button24 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button25 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button26 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button27 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button28 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button29 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController + - id: button30 + capabilities: + - id: button + version: 1 + optional: true + categories: + - name: RemoteController diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/device_configuration.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/device_configuration.lua index 282d33677f..f078f69668 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/device_configuration.lua @@ -10,6 +10,7 @@ local st_utils = require "st.utils" local device_cfg = require "switch_utils.device_configuration" local fields = require "switch_utils.fields" local switch_utils = require "switch_utils.utils" +local update_metadata_request = require "switch_utils.update_metadata_request" local CameraDeviceConfiguration = {} @@ -196,7 +197,7 @@ function CameraDeviceConfiguration.create_child_devices(driver, device) local parent_child_device = false for _, ep in ipairs(device.endpoints or {}) do if device:supports_server_cluster(clusters.OnOff.ID, ep.endpoint_id) then - local child_profile = device_cfg.SwitchCfg.assign_profile_for_onoff_ep(device, ep.endpoint_id) + local child_profile = device_cfg.SwitchCfg.assign_profile_for_onoff_ep(device, ep.endpoint_id).profile if child_profile then num_floodlight_eps = num_floodlight_eps + 1 local name = string.format("%s %d", "Floodlight", num_floodlight_eps) @@ -224,7 +225,7 @@ function CameraDeviceConfiguration.match_profile(device) local status_light_enabled_present, status_light_brightness_present = get_status_light_presence(device) local hard_privacy_mode_present = get_hard_privacy_mode_presence(device) local profile_update_requested = false - local optional_supported_component_capabilities = {} + local updated_metadata = update_metadata_request.init():add_profile("camera") local main_component_capabilities = {} local status_led_component_capabilities = {} local speaker_component_capabilities = {} @@ -313,23 +314,23 @@ function CameraDeviceConfiguration.match_profile(device) table.insert(status_led_component_capabilities, capabilities.mode.ID) end - table.insert(optional_supported_component_capabilities, {camera_fields.profile_components.main, main_component_capabilities}) + updated_metadata:add_capabilities_to_component(camera_fields.profile_components.main, main_component_capabilities) if #status_led_component_capabilities > 0 then - table.insert(optional_supported_component_capabilities, {camera_fields.profile_components.statusLed, status_led_component_capabilities}) + updated_metadata:add_capabilities_to_component(camera_fields.profile_components.statusLed, status_led_component_capabilities) end if #speaker_component_capabilities > 0 then - table.insert(optional_supported_component_capabilities, {camera_fields.profile_components.speaker, speaker_component_capabilities}) + updated_metadata:add_capabilities_to_component(camera_fields.profile_components.speaker, speaker_component_capabilities) end if #microphone_component_capabilities > 0 then - table.insert(optional_supported_component_capabilities, {camera_fields.profile_components.microphone, microphone_component_capabilities}) + updated_metadata:add_capabilities_to_component(camera_fields.profile_components.microphone, microphone_component_capabilities) end if #doorbell_component_capabilities > 0 then - table.insert(optional_supported_component_capabilities, {camera_fields.profile_components.doorbell, doorbell_component_capabilities}) + updated_metadata:add_capabilities_to_component(camera_fields.profile_components.doorbell, doorbell_component_capabilities) end - if camera_utils.optional_capabilities_list_changed(optional_supported_component_capabilities, device.profile.components) then + if camera_utils.optional_capabilities_list_changed(updated_metadata:formatted_enabled_components(), device.profile.components) then profile_update_requested = true - device:try_update_metadata({profile = "camera", optional_component_capabilities = optional_supported_component_capabilities}) + device:try_update_metadata(updated_metadata:format_request()) if #doorbell_endpoints > 0 then CameraDeviceConfiguration.update_doorbell_component_map(device, doorbell_endpoints[1]) button_cfg.configure_buttons(device, device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH})) diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua index db8cfe67d4..6de4a0b395 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/hager/init.lua @@ -7,6 +7,7 @@ local cluster_base = require "st.matter.cluster_base" local device_lib = require "st.device" local buttonCfg = require "switch_utils.device_configuration".ButtonCfg local create_child = require "switch_utils.device_configuration".ChildCfg.create_or_update_child_devices +local update_metadata_request = require "switch_utils.update_metadata_request" local switch_utils = require "switch_utils.utils" local fields = require "switch_utils.fields" local version = require "version" @@ -94,7 +95,7 @@ end local function assign_profile_for_endpoint(device_type_id) local profile = fields.device_type_profile_map[device_type_id] or "switch-binary" return function(device, ep_id, is_child_device) - return profile, nil + return update_metadata_request.init():add_profile(profile) end end diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua b/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua index 8e5f797877..1210d7470b 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua @@ -7,6 +7,7 @@ local version = require "version" local fields = require "switch_utils.fields" local switch_utils = require "switch_utils.utils" local embedded_cluster_utils = require "switch_utils.embedded_cluster_utils" +local update_metadata_request = require "switch_utils.update_metadata_request" -- Include driver-side definitions when lua libs api version is < 11 if version.api < 11 then @@ -36,23 +37,20 @@ function ChildConfiguration.create_or_update_child_devices(driver, device, serve for device_num, ep_id in ipairs(server_cluster_ep_ids) do if ep_id ~= default_endpoint_id then -- don't create a child device that maps to the main endpoint local label_and_name = string.format("%s %d", device.label, device_num) - local child_profile, optional_component_capabilities = assign_profile_fn(device, ep_id, true) + local child_metadata = assign_profile_fn(device, ep_id, true) local existing_child_device = device:get_field(fields.IS_PARENT_CHILD_DEVICE) and switch_utils.find_child(device, ep_id) if not existing_child_device then - device.log.info_with({hub_logs=true}, string.format("Creating child device for endpoint %d with profile %s", ep_id, child_profile)) + device.log.info_with({hub_logs=true}, string.format("Creating child device for endpoint %d with profile %s", ep_id, child_metadata.profile)) driver:try_create_device({ type = "EDGE_CHILD", label = label_and_name, - profile = child_profile, + profile = child_metadata.profile, parent_device_id = device.id, parent_assigned_child_key = string.format("%d", ep_id), vendor_provided_label = label_and_name }) else - existing_child_device:try_update_metadata({ - profile = child_profile, - optional_component_capabilities = optional_component_capabilities - }) + existing_child_device:try_update_metadata(child_metadata:format_request()) end end end @@ -64,8 +62,7 @@ end function FanDeviceConfiguration.assign_profile_for_fan_ep(device, server_fan_ep_id) local ep_info = switch_utils.get_endpoint_info(device, server_fan_ep_id) - local fan_cluster_info = switch_utils.find_cluster_on_ep(ep_info, clusters.FanControl.ID) - local optional_supported_component_capabilities = {} + local fan_cluster_info = switch_utils.find_cluster_on_ep(ep_info, clusters.FanControl.ID) or {} local main_component_capabilities = {} if clusters.FanControl.are_features_supported(clusters.FanControl.types.Feature.MULTI_SPEED, fan_cluster_info.feature_map) then @@ -78,8 +75,9 @@ function FanDeviceConfiguration.assign_profile_for_fan_ep(device, server_fan_ep_ table.insert(main_component_capabilities, capabilities.fanMode.ID) end - table.insert(optional_supported_component_capabilities, {"main", main_component_capabilities}) - return "fan-modular", optional_supported_component_capabilities + return update_metadata_request.init() + :add_profile("fan-modular") + :add_capabilities_to_component("main", main_component_capabilities) end function SwitchDeviceConfiguration.assign_profile_for_onoff_ep(device, server_onoff_ep_id, is_child_device) @@ -104,8 +102,8 @@ function SwitchDeviceConfiguration.assign_profile_for_onoff_ep(device, server_on generic_profile = switch_utils.get_product_override_field(device, "target_profile") or generic_profile end - -- if no supported device type is found, return switch-binary as a generic "OnOff EP" profile - return generic_profile or "switch-binary" + -- if no supported device type is found, use switch-binary as a generic "OnOff EP" profile + return update_metadata_request.init():add_profile(generic_profile or "switch-binary") end -- Per the spec, these attributes are "meant to be changed only during commissioning." @@ -123,31 +121,95 @@ function SwitchDeviceConfiguration.set_device_control_options(device) end end -function ButtonDeviceConfiguration.update_button_profile(device, default_endpoint_id, num_button_eps) - local profile_name = string.gsub(num_button_eps .. "-button", "1%-", "") -- remove the "1-" in a device with 1 button ep - if switch_utils.device_type_supports_button_switch_combination(device, default_endpoint_id) then - profile_name = "light-level-" .. profile_name +--- Assigns a profile for the Generic Switch (button) endpoints of a device, and maps +--- those endpoints to the components of that profile. +--- +--- Button endpoints are generally handled by the modular "button-modular" profile, which +--- enables a button capability on the "main" component and on as many "buttonN" components +--- as the device has additional button endpoints. Devices whose presentation cannot be +--- expressed by the modular profile (combination dimmable light/button devices, and devices +--- with vendor specific preferences or presentation) remain statically profiled. +--- +--- @param device any a Matter device object +--- @param default_endpoint_id number the endpoint mapped to the "main" component +--- @param button_ep_ids table the button endpoint ids to profile, from supported_button_eps +--- @return table|nil update_metadata_request nil if no profile is available for these button endpoints +function ButtonDeviceConfiguration.assign_profile_for_button_eps(device, default_endpoint_id, button_ep_ids) + local static_profile + if switch_utils.get_product_override_field(device, "is_climate_sensor_w100") then + static_profile = "3-button-battery-temperature-humidity" + elseif switch_utils.get_product_override_field(device, "is_ikea_dual_button") then + static_profile = "ikea-2-button-battery" + elseif switch_utils.device_type_supports_button_switch_combination(device, default_endpoint_id) then + if not switch_utils.tbl_contains(fields.STATIC_BUTTON_SWITCH_PROFILE_SUPPORTED, #button_ep_ids) then + device.log.warn_with({hub_logs=true}, string.format( + "No light/button profile available for a device with %d button endpoints", #button_ep_ids)) + return nil + end + -- remove the "1-" in a device with 1 button ep, e.g. "light-level-1-button" -> "light-level-button" + static_profile = "light-level-" .. string.gsub(#button_ep_ids .. "-button", "^1%-", "") end - local motion_eps = device:get_endpoints(clusters.OccupancySensing.ID) - if #motion_eps > 0 and (num_button_eps == 3 or num_button_eps == 6) then -- only these two devices are handled - profile_name = profile_name .. "-motion" + + if static_profile then + ButtonDeviceConfiguration.update_button_component_map(device, default_endpoint_id, button_ep_ids) + return update_metadata_request.init():add_profile(static_profile) end + + local component_map = ButtonDeviceConfiguration.update_button_component_map(device, default_endpoint_id, button_ep_ids, true) + local updated_metadata = update_metadata_request.init():add_profile("button-modular") + local main_component_capabilities = {} + local battery_support = device:get_field(fields.profiling_data.BATTERY_SUPPORT) if battery_support == fields.battery_support.BATTERY_PERCENTAGE then - profile_name = profile_name .. "-battery" + table.insert(main_component_capabilities, capabilities.battery.ID) elseif battery_support == fields.battery_support.BATTERY_LEVEL then - profile_name = profile_name .. "-batteryLevel" + table.insert(main_component_capabilities, capabilities.batteryLevel.ID) end - if switch_utils.get_product_override_field(device, "is_climate_sensor_w100") then - profile_name = "3-button-battery-temperature-humidity" + if #device:get_endpoints(clusters.OccupancySensing.ID) > 0 then + table.insert(main_component_capabilities, capabilities.motionSensor.ID) end - if switch_utils.get_product_override_field(device, "is_ikea_dual_button") then - profile_name = "ikea-2-button-battery" + updated_metadata:add_capabilities_to_component("main", main_component_capabilities) + + -- iterate by component number rather than over the map itself to keep a stable ordering + for component_num = 1, #button_ep_ids do + if component_map["button" .. component_num] then + updated_metadata:add_capabilities_to_component("button" .. component_num, {capabilities.button.ID}) + end + end + + return updated_metadata +end + +--- Returns the button endpoints of a device that the modular button profile has components for, +--- sorted by endpoint id. A device reporting more button endpoints than there are components to +--- hold them is profiled with as many of them as will fit, rather than losing button support entirely. +--- +--- @param device any a Matter device object +--- @param button_ep_ids table the endpoint ids of every momentary switch endpoint +--- @return table the endpoint ids that can be mapped to a component +function ButtonDeviceConfiguration.supported_button_eps(device, button_ep_ids) + table.sort(button_ep_ids) + if #button_ep_ids <= fields.MAX_BUTTON_EPS then + return button_ep_ids + end + + device.log.warn_with({hub_logs=true}, string.format( + "Device reports %d button endpoints, only the first %d will be supported", #button_ep_ids, fields.MAX_BUTTON_EPS)) + local supported_button_ep_ids = {} + for component_num = 1, fields.MAX_BUTTON_EPS do + table.insert(supported_button_ep_ids, button_ep_ids[component_num]) end - return profile_name + return supported_button_ep_ids end -function ButtonDeviceConfiguration.update_button_component_map(device, default_endpoint_id, button_eps) +--- Creates the component mapping for the button endpoints of a device. The endpoint matching +--- default_endpoint_id maps to "main", and every other button endpoint maps to a "buttonN" +--- component, where N is the position of that endpoint in the sorted list of button endpoints. +--- +--- @param always_index_components boolean|nil when falsey, a lone button endpoint that is not the +--- default endpoint maps to the unindexed "button" component of the static light-level-button profile. +--- @return table component_map the map that was set on the device +function ButtonDeviceConfiguration.update_button_component_map(device, default_endpoint_id, button_eps, always_index_components) -- create component mapping on the main profile button endpoints table.sort(button_eps) local component_map = {} @@ -155,13 +217,14 @@ function ButtonDeviceConfiguration.update_button_component_map(device, default_e for component_num, ep in ipairs(button_eps) do if ep ~= default_endpoint_id then local button_component = "button" - if #button_eps > 1 then + if always_index_components or #button_eps > 1 then button_component = button_component .. component_num end component_map[button_component] = ep end end device:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, component_map, {persist = true}) + return component_map end function ButtonDeviceConfiguration.configure_buttons(device, momentary_switch_ep_ids) @@ -198,8 +261,8 @@ function ButtonDeviceConfiguration.configure_buttons(device, momentary_switch_ep end function ValveDeviceConfiguration.assign_profile_for_irrigation_system_ep(device, irrigation_system_ep_id, is_child_device) + local updated_metadata = update_metadata_request.init():add_profile("irrigation-system") local main_component_capabilities = {} - local profile_name = "irrigation-system" local valve_ep_ids = switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.WATER_VALVE) table.sort(valve_ep_ids) @@ -213,7 +276,7 @@ function ValveDeviceConfiguration.assign_profile_for_irrigation_system_ep(device end if is_child_device then - return profile_name, {{"main", main_component_capabilities}} + return updated_metadata:add_capabilities_to_component("main", main_component_capabilities) end local irrigation_system_ep_info = switch_utils.get_endpoint_info(device, irrigation_system_ep_id) @@ -224,7 +287,7 @@ function ValveDeviceConfiguration.assign_profile_for_irrigation_system_ep(device table.insert(main_component_capabilities, capabilities.operationalState.ID) end - return profile_name, {{"main", main_component_capabilities}} + return updated_metadata:add_capabilities_to_component("main", main_component_capabilities) end @@ -260,8 +323,7 @@ function DeviceConfiguration.match_profile(driver, device) if profiling_data_still_required(device) then return end local default_endpoint_id = switch_utils.find_default_endpoint(device) - local optional_component_capabilities - local updated_profile + local updated_metadata = update_metadata_request.init() local server_onoff_ep_ids = device:get_endpoints(clusters.OnOff.ID) -- get_endpoints defaults to return EPs supporting SERVER or BOTH if #server_onoff_ep_ids > 0 then @@ -269,14 +331,15 @@ function DeviceConfiguration.match_profile(driver, device) end if switch_utils.tbl_contains(server_onoff_ep_ids, default_endpoint_id) then - updated_profile = SwitchDeviceConfiguration.assign_profile_for_onoff_ep(device, default_endpoint_id) - local generic_profile = function(s) return string.find(updated_profile or "", s, 1, true) end + updated_metadata = SwitchDeviceConfiguration.assign_profile_for_onoff_ep(device, default_endpoint_id) + local onoff_profile = updated_metadata.profile + local generic_profile = function(s) return string.find(onoff_profile or "", s, 1, true) end if generic_profile("light-level") and #device:get_endpoints(clusters.OccupancySensing.ID) > 0 then - updated_profile = switch_utils.get_product_override_field(device, "target_profile") or "light-level-motion" + updated_metadata:add_profile(switch_utils.get_product_override_field(device, "target_profile") or "light-level-motion") elseif switch_utils.check_switch_category_vendor_overrides(device) then -- check whether the overwrite should be over "plug" or "light" based on the current profile - local overwrite_category = string.find(updated_profile, "plug") and "plug" or "light" - updated_profile = string.gsub(updated_profile, overwrite_category, "switch") + local overwrite_category = string.find(onoff_profile, "plug") and "plug" or "light" + updated_metadata:add_profile(string.gsub(onoff_profile, overwrite_category, "switch")) elseif generic_profile("light-level-colorTemperature") or generic_profile("light-color-level") then -- ignore attempts to dynamically profile light-level-colorTemperature and light-color-level devices for now, since -- these may lose fingerprinted Kelvin ranges when dynamically profiled. @@ -287,30 +350,34 @@ function DeviceConfiguration.match_profile(driver, device) local irrigation_system_ep_ids = switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.IRRIGATION_SYSTEM) local valve_ep_ids = switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.WATER_VALVE) if #irrigation_system_ep_ids > 0 then - updated_profile, optional_component_capabilities = ValveDeviceConfiguration.assign_profile_for_irrigation_system_ep(device, irrigation_system_ep_ids[1], false) + updated_metadata = ValveDeviceConfiguration.assign_profile_for_irrigation_system_ep(device, irrigation_system_ep_ids[1], false) ChildConfiguration.create_or_update_child_devices(driver, device, valve_ep_ids, default_endpoint_id, ValveDeviceConfiguration.assign_profile_for_irrigation_system_ep) elseif #valve_ep_ids > 0 then - updated_profile = "water-valve" + local valve_profile = "water-valve" if #embedded_cluster_utils.get_endpoints(device, clusters.ValveConfigurationAndControl.ID, {feature_bitmap = clusters.ValveConfigurationAndControl.types.Feature.LEVEL}) > 0 then - updated_profile = updated_profile .. "-level" + valve_profile = valve_profile .. "-level" end + updated_metadata = update_metadata_request.init():add_profile(valve_profile) end if #switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.FAN) > 0 then - updated_profile, optional_component_capabilities = FanDeviceConfiguration.assign_profile_for_fan_ep(device, default_endpoint_id) + updated_metadata = FanDeviceConfiguration.assign_profile_for_fan_ep(device, default_endpoint_id) end -- initialize the main device card with buttons if applicable local momentary_switch_ep_ids = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) - if switch_utils.tbl_contains(fields.STATIC_BUTTON_PROFILE_SUPPORTED, #momentary_switch_ep_ids) then - updated_profile = ButtonDeviceConfiguration.update_button_profile(device, default_endpoint_id, #momentary_switch_ep_ids) - -- All button endpoints found will be added as additional components in the profile containing the default_endpoint_id. - ButtonDeviceConfiguration.update_button_component_map(device, default_endpoint_id, momentary_switch_ep_ids) - ButtonDeviceConfiguration.configure_buttons(device, momentary_switch_ep_ids) + if #momentary_switch_ep_ids > 0 then + -- All supported button endpoints will be added as additional components in the profile containing the default_endpoint_id. + local button_ep_ids = ButtonDeviceConfiguration.supported_button_eps(device, momentary_switch_ep_ids) + local button_metadata = ButtonDeviceConfiguration.assign_profile_for_button_eps(device, default_endpoint_id, button_ep_ids) + if button_metadata then + updated_metadata = button_metadata + ButtonDeviceConfiguration.configure_buttons(device, button_ep_ids) + end end - device:try_update_metadata({ profile = updated_profile, optional_component_capabilities = optional_component_capabilities }) + device:try_update_metadata(updated_metadata:format_request()) end return { diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua index 5873cb40b2..67349e657e 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua @@ -199,8 +199,13 @@ SwitchFields.START_BUTTON_PRESS = "__start_button_press" SwitchFields.TIMEOUT_THRESHOLD = 10 --arbitrary timeout SwitchFields.HELD_THRESHOLD = 1 --- this is the number of buttons for which we have a static profile already made -SwitchFields.STATIC_BUTTON_PROFILE_SUPPORTED = {1, 2, 3, 4, 5, 6, 7, 8, 9} +-- the maximum number of button (Generic Switch) endpoints representable by the modular button profile. +-- one endpoint may be mapped to the "main" component, the rest map to the "buttonN" components. +SwitchFields.MAX_BUTTON_EPS = 30 + +-- combination dimmable light/button devices are still profiled statically. This is the +-- number of button endpoints for which we have a "light-level-N-button" profile already made. +SwitchFields.STATIC_BUTTON_SWITCH_PROFILE_SUPPORTED = {1, 2, 3, 4, 5, 6, 7, 8} -- Some switches will send a MultiPressComplete event as part of a long press sequence. Normally the driver will create a -- button capability event on receipt of MultiPressComplete, but in this case that would result in an extra event because diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/update_metadata_request.lua b/drivers/SmartThings/matter-switch/src/switch_utils/update_metadata_request.lua new file mode 100644 index 0000000000..b621453779 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/switch_utils/update_metadata_request.lua @@ -0,0 +1,47 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local UpdateMetadataRequest = {} +UpdateMetadataRequest.__index = UpdateMetadataRequest + +function UpdateMetadataRequest.init() + local update_metadata_request = setmetatable({}, UpdateMetadataRequest) + update_metadata_request.profile = nil + update_metadata_request.enabled_components = {} + return update_metadata_request +end + +function UpdateMetadataRequest:add_capabilities_to_component(component_id, capability_ids) + if not self.enabled_components[component_id] then + self.enabled_components[component_id] = {} + end + for _, capability_id in ipairs(capability_ids) do + table.insert(self.enabled_components[component_id], capability_id) + end + return self +end + +function UpdateMetadataRequest:add_profile(profile) + self.profile = profile + return self +end + +function UpdateMetadataRequest:formatted_enabled_components() + local formatted_enabled_components = {} + for component_id, capability_ids in pairs(self.enabled_components) do + table.insert(formatted_enabled_components, { component_id, capability_ids }) + end + return formatted_enabled_components +end + +function UpdateMetadataRequest:format_request() + local formatted_request = { + profile = self.profile, + -- Since try_update_metadata counts an empty list as a populated field, sending one + -- here would attach a meaningless instruction to every static profile update. + optional_component_capabilities = next(self.enabled_components) and self:formatted_enabled_components() or nil + } + return formatted_request +end + +return UpdateMetadataRequest diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua index 1d12f41781..7a2b2879bc 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua @@ -150,6 +150,7 @@ end --- find_default_endpoint is a helper function to handle situations where --- device does not have endpoint ids in sequential order from 1 +--- @return number endpoint_id the default endpoint id function utils.find_default_endpoint(device) -- Buttons should not be set on the main component for the Aqara Climate Sensor W100, if utils.get_product_override_field(device, "is_climate_sensor_w100") then @@ -163,7 +164,7 @@ function utils.find_default_endpoint(device) return ep end end - return nil + return device.MATTER_DEFAULT_ENDPOINT -- note: this shouldn't ever occur end -- Return the first fan endpoint as the default endpoint if any is found diff --git a/drivers/SmartThings/matter-switch/src/test/test_aqara_light_switch_h2.lua b/drivers/SmartThings/matter-switch/src/test/test_aqara_light_switch_h2.lua index 08402cf273..db7c64b7bd 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_aqara_light_switch_h2.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_aqara_light_switch_h2.lua @@ -24,8 +24,17 @@ local aqara_parent_ep = 4 local aqara_child1_ep = 1 local aqara_child2_ep = 2 +local expected_optional_component_capabilities = { + {"main", {}}, + {"button2", {"button"}}, + {"button3", {"button"}}, + {"button4", {"button"}}, +} + local aqara_mock_device = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("4-button.yml"), + profile = t_utils.get_profile_definition( + "button-modular.yml", { enabled_optional_capabilities = expected_optional_component_capabilities } + ), manufacturer_info = {vendor_id = 0x115F, product_id = 0x1009, product_name = "Aqara Light Switch H2"}, matter_version = {hardware = 1, software = 1}, label = "Aqara Light Switch", @@ -182,7 +191,10 @@ local function test_init() test.socket.matter:__expect_send({aqara_mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "doConfigure" }) configure_buttons() - aqara_mock_device:expect_metadata_update({ profile = "4-button" }) + aqara_mock_device:expect_metadata_update({ + profile = "button-modular", + optional_component_capabilities = expected_optional_component_capabilities + }) aqara_mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) for _, child in pairs(aqara_mock_children) do @@ -206,7 +218,7 @@ local function test_init() }) local device_info_copy = utils.deep_copy(aqara_mock_device.raw_st_data) - device_info_copy.profile.id = "4-button" + device_info_copy.profile.id = "button-modular" local device_info_json = dkjson.encode(device_info_copy) test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "infoChanged", device_info_json }) configure_buttons() diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_button.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_button.lua index d49d9f2452..0e854fdf28 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_button.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_button.lua @@ -99,7 +99,7 @@ test.register_coroutine_test( test.wait_for_events() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) expect_configure_button(mock_device) - mock_device:expect_metadata_update({ profile = "button" }) + mock_device:expect_metadata_update({ profile = "button-modular", optional_component_capabilities = {{"main", {}}} }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) test.wait_for_events() assert(switch_utils.deep_equals(st_utils.deep_copy(mock_device:get_field(fields.COMPONENT_TO_ENDPOINT_MAP)), expected_component_to_endpoint_map), "Component to endpoint map should be set in doConfigure") @@ -532,7 +532,7 @@ test.register_coroutine_test( mock_device_battery, 1, {uint32(clusters.PowerSource.attributes.BatPercentRemaining.ID)} )}) expect_configure_button(mock_device_battery) - mock_device_battery:expect_metadata_update({ profile = "button-battery" }) + mock_device_battery:expect_metadata_update({ profile = "button-modular", optional_component_capabilities = {{"main", {"battery"}}} }) end, { test_init = test_init_profile_change_with_battery, @@ -547,7 +547,7 @@ test.register_coroutine_test( mock_device_battery, 1, {uint32(clusters.PowerSource.attributes.BatChargeLevel.ID)} )}) expect_configure_button(mock_device_battery) - mock_device_battery:expect_metadata_update({ profile = "button-batteryLevel" }) + mock_device_battery:expect_metadata_update({ profile = "button-modular", optional_component_capabilities = {{"main", {"batteryLevel"}}} }) end, { test_init = test_init_profile_change_with_battery, @@ -565,7 +565,7 @@ test.register_coroutine_test( } ) expect_configure_button(mock_device_battery) - mock_device_battery:expect_metadata_update({ profile = "button" }) + mock_device_battery:expect_metadata_update({ profile = "button-modular", optional_component_capabilities = {{"main", {}}} }) test.wait_for_events() assert(mock_device_battery:get_field(fields.profiling_data.BATTERY_SUPPORT) == fields.battery_support.NO_BATTERY, "Device should be marked as not supporting battery") end, diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_modular_button.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_modular_button.lua new file mode 100644 index 0000000000..f55b903022 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_modular_button.lua @@ -0,0 +1,150 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local clusters = require "st.matter.clusters" +local fields = require "switch_utils.fields" +local switch_utils = require "switch_utils.utils" +local st_utils = require "st.utils" + +--- The first button endpoint is mapped to "main", the remaining endpoints are mapped in order +--- to "button2" through "buttonN". +local function expected_optional_component_capabilities(num_button_eps) + local optional_component_capabilities = {{"main", {}}} + for component_num = 2, num_button_eps do + table.insert(optional_component_capabilities, {"button" .. component_num, {"button"}}) + end + return optional_component_capabilities +end + +--- Builds a device with num_button_eps Generic Switch endpoints, all of which only support +--- the MOMENTARY_SWITCH feature. Endpoints are numbered from 1 so that the first button +--- endpoint is also the default endpoint, and is therefore mapped to the "main" component. +local function build_button_device(num_button_eps) + local endpoints = { + { + endpoint_id = 0, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + {device_type_id = 0x0016, device_type_revision = 1} -- RootNode + } + } + } + for endpoint_id = 1, num_button_eps do + table.insert(endpoints, { + endpoint_id = endpoint_id, + 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 + } + }) + end + return test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition( + "button-modular.yml", + { enabled_optional_capabilities = expected_optional_component_capabilities(num_button_eps) } + ), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, + endpoints = endpoints + }) +end + +local function expected_component_to_endpoint_map(num_button_eps) + local component_map = {main = 1} + for component_num = 2, num_button_eps do + component_map["button" .. component_num] = component_num + end + return component_map +end + +local function expect_configure_buttons(device, num_button_eps) + test.socket.capability:__expect_send(device:generate_test_message( + "main", capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}) + )) + for component_num = 2, num_button_eps do + test.socket.capability:__expect_send(device:generate_test_message( + "button" .. component_num, capabilities.button.supportedButtonValues({"pushed"}, {visibility = {displayed = false}}) + )) + end +end + +local mock_device_max_buttons = build_button_device(fields.MAX_BUTTON_EPS) +local mock_device_too_many_buttons = build_button_device(fields.MAX_BUTTON_EPS + 5) + +local function build_test_init(device) + return function() + local cluster_subscribe_list = { + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete, + } + local subscribe_request = cluster_subscribe_list[1]:subscribe(device) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then subscribe_request:merge(cluster:subscribe(device)) end + end + + test.disable_startup_messages() + test.mock_device.add_test_device(device) + device:set_field(fields.profiling_data.POWER_TOPOLOGY, false, {persist = true}) + test.socket.device_lifecycle:__queue_receive({device.id, "init"}) + test.socket.matter:__expect_send({device.id, subscribe_request}) + end +end + +test.register_coroutine_test( + "A device with more button endpoints than the static profiles supported is profiled modularly", + function() + test.socket.device_lifecycle:__queue_receive({mock_device_max_buttons.id, "doConfigure"}) + expect_configure_buttons(mock_device_max_buttons, fields.MAX_BUTTON_EPS) + mock_device_max_buttons:expect_metadata_update({ + profile = "button-modular", + optional_component_capabilities = expected_optional_component_capabilities(fields.MAX_BUTTON_EPS) + }) + mock_device_max_buttons:expect_metadata_update({provisioning_state = "PROVISIONED"}) + test.wait_for_events() + assert(switch_utils.deep_equals( + st_utils.deep_copy(mock_device_max_buttons:get_field(fields.COMPONENT_TO_ENDPOINT_MAP)), + expected_component_to_endpoint_map(fields.MAX_BUTTON_EPS) + ), "Every button endpoint should be mapped to a component") + end, + { + test_init = build_test_init(mock_device_max_buttons), + min_api_version = 15 + } +) + +test.register_coroutine_test( + "A device with more button endpoints than the modular profile supports is profiled with as many as will fit", + function() + test.socket.device_lifecycle:__queue_receive({mock_device_too_many_buttons.id, "doConfigure"}) + expect_configure_buttons(mock_device_too_many_buttons, fields.MAX_BUTTON_EPS) + mock_device_too_many_buttons:expect_metadata_update({ + profile = "button-modular", + optional_component_capabilities = expected_optional_component_capabilities(fields.MAX_BUTTON_EPS) + }) + mock_device_too_many_buttons:expect_metadata_update({provisioning_state = "PROVISIONED"}) + test.wait_for_events() + assert(switch_utils.deep_equals( + st_utils.deep_copy(mock_device_too_many_buttons:get_field(fields.COMPONENT_TO_ENDPOINT_MAP)), + expected_component_to_endpoint_map(fields.MAX_BUTTON_EPS) + ), "Only the first MAX_BUTTON_EPS button endpoints should be mapped to a component") + end, + { + test_init = build_test_init(mock_device_too_many_buttons), + min_api_version = 15 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button.lua index 0f4c1e28b1..5b968a724d 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button.lua @@ -10,9 +10,27 @@ local clusters = require "st.matter.generated.zap_clusters" local button_attr = capabilities.button.button local uint32 = require "st.matter.data_types.Uint32" +--- the modular button profile enables a button capability on "main" and on one "buttonN" +--- component per additional button endpoint. The 5 button endpoints of these mock devices +--- map to main, button2, button3, button4 and button5. +local function modular_button_metadata(main_capabilities) + local optional_component_capabilities = {{"main", main_capabilities}} + for component_num = 2, 5 do + table.insert(optional_component_capabilities, {"button" .. component_num, {"button"}}) + end + return { profile = "button-modular", optional_component_capabilities = optional_component_capabilities } +end + +local function modular_button_profile(main_capabilities) + return t_utils.get_profile_definition( + "button-modular.yml", + { enabled_optional_capabilities = modular_button_metadata(main_capabilities).optional_component_capabilities } + ) +end + local mock_device = test.mock_device.build_test_matter_device( { - profile = t_utils.get_profile_definition("5-button.yml"), + profile = modular_button_profile({}), manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, matter_version = {hardware = 1, software = 1}, endpoints = { @@ -95,7 +113,7 @@ local mock_device = test.mock_device.build_test_matter_device( local mock_device_battery = test.mock_device.build_test_matter_device( { - profile = t_utils.get_profile_definition("5-button-battery.yml"), + profile = modular_button_profile({"battery"}), manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, matter_version = {hardware = 1, software = 1}, endpoints = { @@ -190,7 +208,7 @@ local function update_profile() mock_device_battery, 10, {uint32(clusters.PowerSource.attributes.BatPercentRemaining.ID)} )}) expect_configure_buttons(mock_device_battery) - mock_device_battery:expect_metadata_update({ profile = "5-button-battery" }) + mock_device_battery:expect_metadata_update(modular_button_metadata({"battery"})) end -- All messages queued and expectations set are done before the driver is actually run @@ -219,7 +237,7 @@ local function test_init() --doConfigure sets the provisioning state to provisioned test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) expect_configure_buttons(mock_device) - mock_device:expect_metadata_update({ profile = "5-button" }) + mock_device:expect_metadata_update(modular_button_metadata({})) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end @@ -969,7 +987,7 @@ test.register_coroutine_test( } ) expect_configure_buttons(mock_device) - mock_device:expect_metadata_update({ profile = "5-button-batteryLevel" }) + mock_device:expect_metadata_update(modular_button_metadata({"batteryLevel"})) end, { min_api_version = 15 @@ -990,7 +1008,7 @@ test.register_coroutine_test( } ) expect_configure_buttons(mock_device) - mock_device:expect_metadata_update({ profile = "5-button-battery" }) + mock_device:expect_metadata_update(modular_button_metadata({"battery"})) end, { min_api_version = 15 diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_motion.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_motion.lua index ec2e7ae289..199ff3eb29 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_motion.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_motion.lua @@ -10,9 +10,20 @@ local dkjson = require "dkjson" local clusters = require "st.matter.generated.zap_clusters" local button_attr = capabilities.button.button +local expected_optional_component_capabilities = { + {"main", {"motionSensor"}}, + {"button2", {"button"}}, + {"button3", {"button"}}, + {"button4", {"button"}}, + {"button5", {"button"}}, + {"button6", {"button"}}, +} + local mock_device = test.mock_device.build_test_matter_device( { - profile = t_utils.get_profile_definition("6-button-motion.yml"), -- on a real device we would switch to this, rather than fingerprint to it + profile = t_utils.get_profile_definition( -- on a real device we would switch to this, rather than fingerprint to it + "button-modular.yml", { enabled_optional_capabilities = expected_optional_component_capabilities } + ), manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, matter_version = {hardware = 1, software = 1}, endpoints = { @@ -150,7 +161,7 @@ local function test_init() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) --doConfigure sets the provisioning state to provisioned - mock_device:expect_metadata_update({ profile = "6-button-motion" }) + mock_device:expect_metadata_update({ profile = "button-modular", optional_component_capabilities = expected_optional_component_capabilities }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) expect_configure_buttons() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_switch_mcd.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_switch_mcd.lua index b3390edc29..c2e488cd64 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_switch_mcd.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_switch_mcd.lua @@ -384,12 +384,17 @@ test.register_coroutine_test( parent_assigned_child_key = string.format("%d", 7) }) test.socket.capability:__expect_send(unsup_mock_device:generate_test_message("main", capabilities.button.supportedButtonValues({"pushed", "held"}, {visibility = {displayed = false}}))) - unsup_mock_device:expect_metadata_update({ profile = "2-button" }) + unsup_mock_device:expect_metadata_update({ + profile = "button-modular", + optional_component_capabilities = {{"main", {}}, {"button2", {"button"}}} + }) unsup_mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) test.wait_for_events() - local updated_device_profile = t_utils.get_profile_definition("2-button.yml") + local updated_device_profile = t_utils.get_profile_definition( + "button-modular.yml", { enabled_optional_capabilities = {{"main", {}}, {"button2", {"button"}}} } + ) test.socket.device_lifecycle:__queue_receive(unsup_mock_device:generate_info_changed({ profile = updated_device_profile })) local CLUSTER_SUBSCRIBE_LIST = {