diff --git a/drivers/SmartThings/matter-switch/fingerprints.yml b/drivers/SmartThings/matter-switch/fingerprints.yml index a53fc144a6..5b3c707e83 100644 --- a/drivers/SmartThings/matter-switch/fingerprints.yml +++ b/drivers/SmartThings/matter-switch/fingerprints.yml @@ -3599,6 +3599,54 @@ matterGeneric: deviceTypes: - id: 0x0142 # Camera deviceProfileName: camera + - id: "matter/on-off/fan/light" + deviceLabel: Matter OnOff Fan Light + deviceTypes: + - id: 0x002B # Fan + - id: 0x0100 # OnOff Light + deviceProfileName: fan-modular + - id: "matter/dimmable/fan/light" + deviceLabel: Matter Dimmable Fan Light + deviceTypes: + - id: 0x002B # Fan + - id: 0x0101 # Dimmable Light + deviceProfileName: fan-modular + - id: "matter/colorTemperature/fan/light" + deviceLabel: Matter Color Temperature Fan Light + deviceTypes: + - id: 0x002B # Fan + - id: 0x010C # Color Temperature Light + deviceProfileName: fan-modular + - id: "matter/color/fan/light" + deviceLabel: Matter Color Fan Light + deviceTypes: + - id: 0x002B # Fan + - id: 0x010D # Extended Color Light + deviceProfileName: fan-modular + - id: "matter/on-off/fan/plug" + deviceLabel: Matter OnOff Fan Plug + deviceTypes: + - id: 0x002B # Fan + - id: 0x010A # On Off Plug-in Unit + deviceProfileName: fan-modular + - id: "matter/dimmable/fan/plug" + deviceLabel: Matter Dimmable Fan Plug + deviceTypes: + - id: 0x002B # Fan + - id: 0x010B # Dimmable Plug-in Unit + deviceProfileName: fan-modular + - id: "matter/mounted/on-off/control/fan" + deviceLabel: Matter Mounted OnOff Control Fan + deviceTypes: + - id: 0x002B # Fan + - id: 0x010F # Mounted On/Off Control + deviceProfileName: fan-modular + - id: "matter/mounted/dim/load/control/fan" + deviceLabel: Matter Mounted Dimmable Load Control Fan + deviceTypes: + - id: 0x002B # Fan + - id: 0x0110 # Mounted Dimmable Load Control + deviceProfileName: fan-modular matterThing: - id: SmartThings/MatterThing diff --git a/drivers/SmartThings/matter-switch/profiles/fan-modular.yml b/drivers/SmartThings/matter-switch/profiles/fan-modular.yml new file mode 100644 index 0000000000..878c9ed615 --- /dev/null +++ b/drivers/SmartThings/matter-switch/profiles/fan-modular.yml @@ -0,0 +1,17 @@ +name: fan-modular +components: +- id: main + capabilities: + - id: fanMode + version: 1 + optional: true + - id: fanSpeedPercent + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Fan + diff --git a/drivers/SmartThings/matter-switch/src/init.lua b/drivers/SmartThings/matter-switch/src/init.lua index 5436d57baf..584b786468 100644 --- a/drivers/SmartThings/matter-switch/src/init.lua +++ b/drivers/SmartThings/matter-switch/src/init.lua @@ -59,7 +59,8 @@ function SwitchLifecycleHandlers.driver_switched(driver, device) end function SwitchLifecycleHandlers.info_changed(driver, device, event, args) - if device.profile.id ~= args.old_st_store.profile.id then + if device.profile.id ~= args.old_st_store.profile.id or device:get_field(fields.MODULAR_PROFILE_UPDATED) then + device:set_field(fields.MODULAR_PROFILE_UPDATED, nil) device:subscribe() local button_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) if #button_eps > 0 and device.network_type == device_lib.NETWORK_TYPE_MATTER then 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 750c9eb50c..d8f27ee7ee 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua @@ -16,8 +16,59 @@ if version.api < 11 then end local DeviceConfiguration = {} +local ChildConfiguration = {} local SwitchDeviceConfiguration = {} local ButtonDeviceConfiguration = {} +local FanDeviceConfiguration = {} + +function ChildConfiguration.create_child_devices(driver, device, server_cluster_ep_ids, default_endpoint_id, assign_profile_fn) + if #server_cluster_ep_ids == 1 and server_cluster_ep_ids[1] == default_endpoint_id then -- no children will be created + return + end + + table.sort(server_cluster_ep_ids) + 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, _ = assign_profile_fn(device, ep_id, true) + driver:try_create_device( + { + type = "EDGE_CHILD", + label = label_and_name, + profile = child_profile, + parent_device_id = device.id, + parent_assigned_child_key = string.format("%d", ep_id), + vendor_provided_label = label_and_name + } + ) + end + end + + -- Persist so that the find_child function is always set on each driver init. + device:set_field(fields.IS_PARENT_CHILD_DEVICE, true, {persist = true}) + device:set_find_child(switch_utils.find_child) +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 main_component_capabilities = {} + + if clusters.FanControl.are_features_supported(clusters.FanControl.types.Feature.MULTI_SPEED, fan_cluster_info.feature_map) then + table.insert(main_component_capabilities, capabilities.fanSpeedPercent.ID) + -- only fanMode can trigger AUTO, so a multi-speed fan still requires this capability if it supports AUTO + if clusters.FanControl.are_features_supported(clusters.FanControl.types.Feature.AUTO, fan_cluster_info.feature_map) then + table.insert(main_component_capabilities, capabilities.fanMode.ID) + end + else -- MULTI_SPEED is not supported + 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 +end + function SwitchDeviceConfiguration.assign_profile_for_onoff_ep(device, server_onoff_ep_id, is_child_device) local ep_info = switch_utils.get_endpoint_info(device, server_onoff_ep_id) @@ -43,34 +94,6 @@ function SwitchDeviceConfiguration.assign_profile_for_onoff_ep(device, server_on return generic_profile or "switch-binary" end -function SwitchDeviceConfiguration.create_child_devices(driver, device, server_onoff_ep_ids, default_endpoint_id) - if #server_onoff_ep_ids == 1 and server_onoff_ep_ids[1] == default_endpoint_id then -- no children will be created - return - end - - table.sort(server_onoff_ep_ids) - for device_num, ep_id in ipairs(server_onoff_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 = SwitchDeviceConfiguration.assign_profile_for_onoff_ep(device, ep_id, true) - driver:try_create_device( - { - type = "EDGE_CHILD", - label = label_and_name, - profile = child_profile, - parent_device_id = device.id, - parent_assigned_child_key = string.format("%d", ep_id), - vendor_provided_label = label_and_name - } - ) - end - end - - -- Persist so that the find_child function is always set on each driver init. - device:set_field(fields.IS_PARENT_CHILD_DEVICE, true, {persist = true}) - device:set_find_child(switch_utils.find_child) -end - -- Per the spec, these attributes are "meant to be changed only during commissioning." function SwitchDeviceConfiguration.set_device_control_options(device) for _, ep in ipairs(device.endpoints) do @@ -172,6 +195,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 if #embedded_cluster_utils.get_endpoints(device, clusters.ValveConfigurationAndControl.ID) > 0 then @@ -184,15 +208,13 @@ function DeviceConfiguration.match_profile(driver, device) 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 - SwitchDeviceConfiguration.create_child_devices(driver, device, server_onoff_ep_ids, default_endpoint_id) + ChildConfiguration.create_child_devices(driver, device, server_onoff_ep_ids, default_endpoint_id, SwitchDeviceConfiguration.assign_profile_for_onoff_ep) 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 - if generic_profile("light-color-level") and #device:get_endpoints(clusters.FanControl.ID) > 0 then - updated_profile = "light-color-level-fan" - elseif generic_profile("light-level") and #device:get_endpoints(clusters.OccupancySensing.ID) > 0 then + if generic_profile("light-level") and #device:get_endpoints(clusters.OccupancySensing.ID) > 0 then updated_profile = "light-level-motion" elseif generic_profile("plug-binary") or generic_profile("plug-level") then if switch_utils.check_switch_category_vendor_overrides(device) then @@ -205,6 +227,12 @@ function DeviceConfiguration.match_profile(driver, device) end end + local fan_device_type_ep_ids = switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.FAN) + if #fan_device_type_ep_ids > 0 then + updated_profile, optional_component_capabilities = FanDeviceConfiguration.assign_profile_for_fan_ep(device, default_endpoint_id) + device:set_field(fields.MODULAR_PROFILE_UPDATED, true) + end + -- initialize the main device card with buttons if applicable local button_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) if switch_utils.tbl_contains(fields.STATIC_BUTTON_PROFILE_SUPPORTED, #button_eps) then @@ -215,7 +243,7 @@ function DeviceConfiguration.match_profile(driver, device) return end - device:try_update_metadata({ profile = updated_profile }) + device:try_update_metadata({ profile = updated_profile, optional_component_capabilities = optional_component_capabilities }) 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 f0fd0166b4..6247883c96 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua @@ -42,6 +42,7 @@ SwitchFields.DEVICE_TYPE_ID = { DIMMABLE_PLUG_IN_UNIT = 0x010B, DOORBELL = 0x0143, ELECTRICAL_SENSOR = 0x0510, + FAN = 0x002B, GENERIC_SWITCH = 0x000F, MOUNTED_ON_OFF_CONTROL = 0x010F, MOUNTED_DIMMABLE_LOAD_CONTROL = 0x0110, @@ -151,6 +152,8 @@ SwitchFields.ELECTRICAL_SENSOR_EPS = "__electrical_sensor_eps" --- for an Electrical Sensor EP with a "primary" endpoint, used during device profling. SwitchFields.ELECTRICAL_TAGS = "__electrical_tags" +SwitchFields.MODULAR_PROFILE_UPDATED = "__modular_profile_updated" + SwitchFields.profiling_data = { POWER_TOPOLOGY = "__power_topology", } diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua index b258688234..1664d69e56 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua @@ -151,8 +151,9 @@ function utils.find_default_endpoint(device) return device.MATTER_DEFAULT_ENDPOINT end - local switch_eps = device:get_endpoints(clusters.OnOff.ID) - local button_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) + local onoff_ep_ids = device:get_endpoints(clusters.OnOff.ID) + local momentary_switch_ep_ids = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) + local fan_endpoint_ids = utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.FAN) local get_first_non_zero_endpoint = function(endpoints) table.sort(endpoints) @@ -164,26 +165,31 @@ function utils.find_default_endpoint(device) return nil end - -- Return the first switch endpoint as the default endpoint if no button endpoints are present - if #button_eps == 0 and #switch_eps > 0 then - return get_first_non_zero_endpoint(switch_eps) + -- Return the first fan endpoint as the default endpoint if any is found + if #fan_endpoint_ids > 0 then + return get_first_non_zero_endpoint(fan_endpoint_ids) end - -- Return the first button endpoint as the default endpoint if no switch endpoints are present - if #switch_eps == 0 and #button_eps > 0 then - return get_first_non_zero_endpoint(button_eps) + -- Return the first onoff endpoint as the default endpoint if no momentary switch endpoints are present + if #momentary_switch_ep_ids == 0 and #onoff_ep_ids > 0 then + return get_first_non_zero_endpoint(onoff_ep_ids) end - -- If both switch and button endpoints are present, check the device type on the main switch + -- Return the first momentary switch endpoint as the default endpoint if no onoff endpoints are present + if #onoff_ep_ids == 0 and #momentary_switch_ep_ids > 0 then + return get_first_non_zero_endpoint(momentary_switch_ep_ids) + end + + -- If both onoff and momentary switch endpoints are present, check the device type on the main switch -- endpoint. If it is not a supported device type, return the first button endpoint as the -- default endpoint. - if #switch_eps > 0 and #button_eps > 0 then - local default_endpoint_id = get_first_non_zero_endpoint(switch_eps) + if #onoff_ep_ids > 0 and #momentary_switch_ep_ids > 0 then + local default_endpoint_id = get_first_non_zero_endpoint(onoff_ep_ids) if utils.device_type_supports_button_switch_combination(device, default_endpoint_id) then return default_endpoint_id else device.log.warn("The main switch endpoint does not contain a supported device type for a component configuration with buttons") - return get_first_non_zero_endpoint(button_eps) + return get_first_non_zero_endpoint(momentary_switch_ep_ids) end end diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_light_fan.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_light_fan.lua index a35f5bda50..e39e0a95c5 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_light_fan.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_light_fan.lua @@ -15,8 +15,8 @@ local mock_device_ep1 = 1 local mock_device_ep2 = 2 local mock_device = test.mock_device.build_test_matter_device({ - label = "Matter Switch", - profile = t_utils.get_profile_definition("light-color-level-fan.yml"), + label = "Matter Fan Light", + profile = t_utils.get_profile_definition("fan-modular.yml", {}), manufacturer_info = { vendor_id = 0x0000, product_id = 0x0000, @@ -34,7 +34,7 @@ local mock_device = test.mock_device.build_test_matter_device({ { endpoint_id = mock_device_ep1, clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", feature_map = 0}, {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2}, {cluster_id = clusters.ColorControl.ID, cluster_type = "BOTH", feature_map = 30}, }, @@ -66,22 +66,29 @@ local CLUSTER_SUBSCRIBE_LIST ={ clusters.ColorControl.attributes.CurrentSaturation, clusters.ColorControl.attributes.CurrentX, clusters.ColorControl.attributes.CurrentY, - clusters.ColorControl.attributes.ColorMode, clusters.FanControl.attributes.FanModeSequence, clusters.FanControl.attributes.FanMode, clusters.FanControl.attributes.PercentCurrent, } +local mock_child = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("light-color-level.yml"), + device_network_id = string.format("%s:%d", mock_device.id, 4), + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%d", mock_device_ep1) +}) + local function test_init() test.disable_startup_messages() test.mock_device.add_test_device(mock_device) + test.mock_device.add_test_device(mock_child) 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, "added" }) - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) -- since all fan capabilities are optional, nothing is initially subscribed to test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) @@ -89,8 +96,21 @@ local function test_init() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) test.socket.matter:__expect_send({mock_device.id, clusters.LevelControl.attributes.Options:write(mock_device, mock_device_ep1, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) test.socket.matter:__expect_send({mock_device.id, clusters.ColorControl.attributes.Options:write(mock_device, mock_device_ep1, clusters.ColorControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) - mock_device:expect_metadata_update({ profile = "light-color-level-fan" }) + mock_device:expect_device_create({ + type = "EDGE_CHILD", + label = "Matter Fan Light 1", + profile = "light-color-level", + parent_device_id = mock_device.id, + parent_assigned_child_key = string.format("%d", mock_device_ep1) + }) + mock_device:expect_metadata_update({ profile = "fan-modular", optional_component_capabilities = {{"main", {"fanSpeedPercent", "fanMode"}}} }) mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + + local updated_device_profile = t_utils.get_profile_definition("fan-modular.yml", + {enabled_optional_capabilities = {{"main", {"fanSpeedPercent", "fanMode"}}}} + ) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = updated_device_profile })) + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) end test.set_test_init_function(test_init) @@ -99,7 +119,7 @@ test.register_coroutine_test( "Switch capability should send the appropriate commands", function() test.socket.capability:__queue_receive( { - mock_device.id, + mock_child.id, { capability = "switch", component = "main", command = "on", args = { } } } ) @@ -107,14 +127,14 @@ test.register_coroutine_test( test.socket.devices:__expect_send( { "register_native_capability_cmd_handler", - { device_uuid = mock_device.id, capability_id = "switch", capability_cmd_id = "on" } + { device_uuid = mock_child.id, capability_id = "switch", capability_cmd_id = "on" } } ) end test.socket.matter:__expect_send( { mock_device.id, - clusters.OnOff.server.commands.On(mock_device, 1) + clusters.OnOff.server.commands.On(mock_device, mock_device_ep1) } ) test.socket.matter:__queue_receive( @@ -129,9 +149,8 @@ test.register_coroutine_test( { device_uuid = mock_device.id, capability_id = "switch", capability_attr_id = "switch" } } ) - test.socket.capability:__expect_send( - mock_device:generate_test_message( + mock_child:generate_test_message( "main", capabilities.switch.switch.on() ) ) @@ -145,7 +164,7 @@ test.register_message_test( channel = "capability", direction = "receive", message = { - mock_device.id, + mock_child.id, { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = {1800} } } }, @@ -176,7 +195,7 @@ test.register_message_test( { channel = "capability", direction = "send", - message = mock_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(1800)) + message = mock_child:generate_test_message("main", capabilities.colorTemperature.colorTemperature(1800)) }, } )