diff --git a/drivers/SmartThings/matter-switch/src/init.lua b/drivers/SmartThings/matter-switch/src/init.lua index 9d4e22fb15..022aa11b78 100644 --- a/drivers/SmartThings/matter-switch/src/init.lua +++ b/drivers/SmartThings/matter-switch/src/init.lua @@ -106,7 +106,7 @@ function SwitchLifecycleHandlers.device_init(driver, device) if #device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) == 0 then device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, {persist = true}) end - switch_utils.handle_electrical_sensor_info(device) + switch_utils.cache_electrical_cluster_data(device) device:extend_device("subscribe", switch_utils.subscribe) device:subscribe() end diff --git a/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua index b7feed6344..9d35e14072 100644 --- a/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua @@ -338,7 +338,7 @@ function AttributeHandlers.available_endpoints_handler(driver, device, ib, respo end -- set the required profile elements ("-power", etc.) to one of these EP IDs for later profiling. -- set an assigned child key in the case this will emit events on an EDGE_CHILD device - switch_utils.set_fields_for_electrical_sensor_endpoint(device, set_ep_info, available_endpoints_ids) + switch_utils.cache_electrical_data_from_endpoint(device, set_ep_info, available_endpoints_ids) break end end @@ -371,7 +371,7 @@ function AttributeHandlers.parts_list_handler(driver, device, ib, response) end -- set the required profile elements ("-power", etc.) to one of these EP IDs for later profiling. -- set an assigned child key in the case this will emit events on an EDGE_CHILD device - switch_utils.set_fields_for_electrical_sensor_endpoint(device, tree_ep_info, associated_endpoints_ids) + switch_utils.cache_electrical_data_from_endpoint(device, tree_ep_info, associated_endpoints_ids) break end end diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua index d2457bfb4d..db952f314e 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua @@ -413,50 +413,45 @@ function utils.report_power_consumption_to_st_energy(device, endpoint_id, total_ })) end ---- sets fields for handling EPs with the Electrical Sensor device type +--- Sets fields for handling electrical clusters, including storage of electrical tags (like "-energy-powerConsumption", etc) +--- and associating the clusters with the appropriate devices via the find_child implementation --- --- @param device table a Matter device object ---- @param electrical_sensor_ep table an EP object that includes an Electrical Sensor device type ---- @param associated_endpoint_ids table EP IDs that are associated with the Electrical Sensor EP +--- @param electrical_ep table an EP that includes electrical clusters. +--- @param associated_ep_ids table EP IDs that should be associated with the electrical clusters --- @return boolean -function utils.set_fields_for_electrical_sensor_endpoint(device, electrical_sensor_ep, associated_endpoint_ids) - if #associated_endpoint_ids == 0 then +function utils.cache_electrical_data_from_endpoint(device, electrical_ep, associated_ep_ids) + if #associated_ep_ids == 0 then return false else local tags = "" - if utils.find_cluster_on_ep(electrical_sensor_ep, clusters.ElectricalPowerMeasurement.ID) then tags = tags.."-power" end - if utils.find_cluster_on_ep(electrical_sensor_ep, clusters.ElectricalEnergyMeasurement.ID) then tags = tags.."-energy-powerConsumption" end + if utils.find_cluster_on_ep(electrical_ep, clusters.ElectricalPowerMeasurement.ID) then tags = tags.."-power" end + if utils.find_cluster_on_ep(electrical_ep, clusters.ElectricalEnergyMeasurement.ID) then tags = tags.."-energy-powerConsumption" end + if tags == "" then return false end -- note: using the lowest valued EP ID here is arbitrary (not spec defined) and is done to create internal consistency -- Ex. for the NODE topology, electrical capabilities will then be associated with the default (aka lowest ID'd) OnOff EP - table.sort(associated_endpoint_ids) - local primary_associated_ep_id = associated_endpoint_ids[1] + table.sort(associated_ep_ids) + local primary_associated_ep_id = associated_ep_ids[1] -- map the required electrical tags for this electrical sensor EP with the first associated EP ID, used later during profling. - utils.set_field_for_endpoint(device, fields.ELECTRICAL_TAGS, primary_associated_ep_id, tags, {persist = true}) - utils.set_field_for_endpoint(device, fields.ASSIGNED_CHILD_KEY, electrical_sensor_ep.endpoint_id, string.format("%d", primary_associated_ep_id), { persist = true }) + utils.set_field_for_endpoint(device, fields.ELECTRICAL_TAGS, primary_associated_ep_id, tags) + utils.set_field_for_endpoint(device, fields.ASSIGNED_CHILD_KEY, electrical_ep.endpoint_id, string.format("%d", primary_associated_ep_id), { persist = true }) return true end end -function utils.handle_electrical_sensor_info(device) - local electrical_sensor_eps = utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.ELECTRICAL_SENSOR, { with_info = true }) - if #electrical_sensor_eps == 0 then - -- no Electrical Sensor EPs are supported. Set profiling data to false and return - device:set_field(fields.profiling_data.POWER_TOPOLOGY, false, {persist=true}) - return - end - +function utils.cache_electrical_cluster_data(device) -- energy reporting must be handled by a cumulative report, a periodic report, or both attributes simultaneously. -- To ensure a single source of truth, we only handle a device's periodic reporting if cumulative reporting is not supported. - for _, ep_info in ipairs(electrical_sensor_eps) do - if utils.find_cluster_on_ep(ep_info, clusters.ElectricalEnergyMeasurement.ID, - {feature_bitmap = clusters.ElectricalEnergyMeasurement.types.Feature.CUMULATIVE_ENERGY}) then - device:set_field(fields.CUMULATIVE_REPORTS_SUPPORTED, true) - break - end + if #device:get_endpoints(clusters.ElectricalEnergyMeasurement.ID, + { feature_bitmap = clusters.ElectricalEnergyMeasurement.types.Feature.CUMULATIVE_ENERGY }) > 0 then + device:set_field(fields.CUMULATIVE_REPORTS_SUPPORTED, true) end - -- check the feature map for the first (or only) Electrical Sensor EP if the device profiling has not been completed - if device:get_field(fields.profiling_data.POWER_TOPOLOGY) == nil then + -- if this data has already been cached, we don't need to proceed further + if device:get_field(fields.profiling_data.POWER_TOPOLOGY) then return end + local electrical_sensor_eps = utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.ELECTRICAL_SENSOR, { with_info = true }) + if #electrical_sensor_eps > 0 then + -- check the feature map for the first (or only) Electrical Sensor EP local endpoint_power_topology_cluster = utils.find_cluster_on_ep(electrical_sensor_eps[1], clusters.PowerTopology.ID) or {} local endpoint_power_topology_feature_map = endpoint_power_topology_cluster.feature_map or 0 if clusters.PowerTopology.are_features_supported(clusters.PowerTopology.types.Feature.SET_TOPOLOGY, endpoint_power_topology_feature_map) or @@ -467,10 +462,31 @@ function utils.handle_electrical_sensor_info(device) elseif clusters.PowerTopology.are_features_supported(clusters.PowerTopology.types.Feature.NODE_TOPOLOGY, endpoint_power_topology_feature_map) then -- EP has a NODE topology, so there is only ONE Electrical Sensor EP device:set_field(fields.profiling_data.POWER_TOPOLOGY, clusters.PowerTopology.types.Feature.NODE_TOPOLOGY, {persist=true}) - if utils.set_fields_for_electrical_sensor_endpoint(device, electrical_sensor_eps[1], device:get_endpoints(clusters.OnOff.ID)) == false then + if utils.cache_electrical_data_from_endpoint(device, electrical_sensor_eps[1], device:get_endpoints(clusters.OnOff.ID)) == false then device.log.warn("Electrical Sensor EP with NODE topology found, but no OnOff EPs exist. Electrical Sensor capabilities will not be exposed.") end end + else + -- no electrical sensor endpoints are available, so mark power topology as absent + device:set_field(fields.profiling_data.POWER_TOPOLOGY, false, {persist=true}) + -- return early if electrical cluster endpoints are absent + local energy_meas_ep_ids = device:get_endpoints(clusters.ElectricalEnergyMeasurement.ID) + local power_meas_ep_ids = device:get_endpoints(clusters.ElectricalPowerMeasurement.ID) + if #energy_meas_ep_ids == 0 and #power_meas_ep_ids == 0 then return end + -- try to cache electrical data from each OnOff cluster endpoint + local endpoint_mapping_successful = false + for _, ep in ipairs(device.endpoints) do + if utils.find_cluster_on_ep(ep, clusters.OnOff.ID) then + endpoint_mapping_successful = utils.cache_electrical_data_from_endpoint(device, ep, { ep.endpoint_id }) or endpoint_mapping_successful + end + end + if endpoint_mapping_successful then return end + -- if all else fails, try to directly map the first (hopefully only) energy endpoint to an OnOff endpoint, with an assumption + -- that if a power cluster exists, it will be on the same endpoint as the energy one. This should only really happen if these + -- lone electrical endpoints exist without corresponding OnOff endpoints, which means they are likely attached to the root node, + -- which is against spec, or some device structure we don't yet support + local energy_ep_info = utils.get_endpoint_info(device, energy_meas_ep_ids[1]) + utils.cache_electrical_data_from_endpoint(device, energy_ep_info, device:get_endpoints(clusters.OnOff.ID)) end end diff --git a/drivers/SmartThings/matter-switch/src/test/test_electrical_measurement.lua b/drivers/SmartThings/matter-switch/src/test/test_electrical_measurement.lua new file mode 100644 index 0000000000..b591a3c462 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_electrical_measurement.lua @@ -0,0 +1,188 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local clusters = require "st.matter.clusters" +local t_utils = require "integration_test.utils" +local version = require "version" +local switch_fields = require "switch_utils.fields" + +if version.api < 11 then + clusters.ElectricalEnergyMeasurement = require "embedded_clusters.ElectricalEnergyMeasurement" + clusters.ElectricalPowerMeasurement = require "embedded_clusters.ElectricalPowerMeasurement" + clusters.PowerTopology = require "embedded_clusters.PowerTopology" +end + +local mock_device_with_electrical_measurement_clusters = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("plug-power-energy-powerConsumption.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + endpoints = { + { + endpoint_id = 2, + clusters = { + { cluster_id = clusters.ElectricalEnergyMeasurement.ID, cluster_type = "SERVER", feature_map = 14, }, + { cluster_id = clusters.ElectricalPowerMeasurement.ID, cluster_type = "SERVER", feature_map = 0, }, + { cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0, }, + }, + device_types = { + { device_type_id = switch_fields.DEVICE_TYPE_ID.ON_OFF_PLUG_IN_UNIT, device_type_revision = 1 }, + } + } + }, +}) + +local mock_device_with_electrical_measurement_clusters_on_root = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("plug-power-energy-powerConsumption.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.ElectricalEnergyMeasurement.ID, cluster_type = "SERVER", feature_map = 14, }, + { cluster_id = clusters.ElectricalPowerMeasurement.ID, cluster_type = "SERVER", feature_map = 0, }, + }, + device_types = { + { device_type_id = 0x0016, device_type_revision = 1 }, + }, + }, + { + endpoint_id = 2, + clusters = { + { cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", cluster_revision = 1, feature_map = 0, }, + }, + device_types = { + { device_type_id = switch_fields.DEVICE_TYPE_ID.ON_OFF_PLUG_IN_UNIT, device_type_revision = 1 }, + } + } + }, +}) + +local function test_init() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_with_electrical_measurement_clusters) +end +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "Test init for OnOff Plug with Electrical Measurement clusters is handled properly", + function() + local subscribed_attributes = { + clusters.OnOff.attributes.OnOff, + clusters.ElectricalPowerMeasurement.attributes.ActivePower, + clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported, + clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported, + } + local subscribe_request = subscribed_attributes[1]:subscribe(mock_device_with_electrical_measurement_clusters) + for i, cluster in ipairs(subscribed_attributes) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_with_electrical_measurement_clusters)) + end + end + test.socket.device_lifecycle:__queue_receive({ mock_device_with_electrical_measurement_clusters.id, "init" }) + test.socket.matter:__expect_send({ mock_device_with_electrical_measurement_clusters.id, subscribe_request }) + test.wait_for_events() + print(mock_device_with_electrical_measurement_clusters:get_field(switch_fields.ELECTRICAL_TAGS .. "_2")) + assert(mock_device_with_electrical_measurement_clusters:get_field(switch_fields.ELECTRICAL_TAGS .. "_2") == "-power-energy-powerConsumption", "Expected electrical tags on EP 2 field to be set correctly") + assert(mock_device_with_electrical_measurement_clusters:get_field(switch_fields.CUMULATIVE_REPORTS_SUPPORTED) == true, "Expected cumulative reports supported field to be set to true") + assert(mock_device_with_electrical_measurement_clusters:get_field(switch_fields.ASSIGNED_CHILD_KEY .. "_2") == "2", "Expected assigned child key to be correctly set to the associated EP 2 ID") + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test init for OnOff Plug with Electrical Measurement clusters on Root is handled properly", + function() + local subscribed_attributes = { + clusters.OnOff.attributes.OnOff, + clusters.ElectricalPowerMeasurement.attributes.ActivePower, + clusters.ElectricalEnergyMeasurement.attributes.CumulativeEnergyImported, + clusters.ElectricalEnergyMeasurement.attributes.PeriodicEnergyImported, + } + local subscribe_request = subscribed_attributes[1]:subscribe(mock_device_with_electrical_measurement_clusters_on_root) + for i, cluster in ipairs(subscribed_attributes) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_with_electrical_measurement_clusters_on_root)) + end + end + test.socket.device_lifecycle:__queue_receive({ mock_device_with_electrical_measurement_clusters_on_root.id, "init" }) + test.socket.matter:__expect_send({ mock_device_with_electrical_measurement_clusters_on_root.id, subscribe_request }) + test.wait_for_events() + assert(mock_device_with_electrical_measurement_clusters_on_root:get_field(switch_fields.ELECTRICAL_TAGS .. "_2") == "-power-energy-powerConsumption", "Expected electrical tags on EP 2 field to be set correctly") + assert(mock_device_with_electrical_measurement_clusters_on_root:get_field(switch_fields.CUMULATIVE_REPORTS_SUPPORTED) == true, "Expected cumulative reports supported field to be set to true") + assert(mock_device_with_electrical_measurement_clusters_on_root:get_field(switch_fields.ASSIGNED_CHILD_KEY .. "_0") == "2", "Expected assigned child key to be correctly set to the associated EP 2 ID") + end, + { + test_init = function() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_with_electrical_measurement_clusters_on_root) + end, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test doConfigure for OnOff Plug with Electrical Measurement clusters is handled properly", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_with_electrical_measurement_clusters.id, "doConfigure" }) + mock_device_with_electrical_measurement_clusters:expect_metadata_update({ profile = "plug-power-energy-powerConsumption" }) + mock_device_with_electrical_measurement_clusters:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = function() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_with_electrical_measurement_clusters) + mock_device_with_electrical_measurement_clusters:set_field(switch_fields.ELECTRICAL_TAGS .. "_2", "-power-energy-powerConsumption", {persist = true}) + mock_device_with_electrical_measurement_clusters:set_field(switch_fields.profiling_data.BATTERY_SUPPORT, false, {persist = true}) + mock_device_with_electrical_measurement_clusters:set_field(switch_fields.profiling_data.POWER_TOPOLOGY, false, {persist = true}) + end, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test doConfigure for OnOff Plug with some Electrical Power Measurement cluster is handled properly", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_with_electrical_measurement_clusters.id, "doConfigure" }) + mock_device_with_electrical_measurement_clusters:expect_metadata_update({ profile = "plug-power" }) + mock_device_with_electrical_measurement_clusters:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = function() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_with_electrical_measurement_clusters) + mock_device_with_electrical_measurement_clusters:set_field(switch_fields.ELECTRICAL_TAGS .. "_2", "-power", {persist = true}) + mock_device_with_electrical_measurement_clusters:set_field(switch_fields.profiling_data.BATTERY_SUPPORT, false, {persist = true}) + mock_device_with_electrical_measurement_clusters:set_field(switch_fields.profiling_data.POWER_TOPOLOGY, false, {persist = true}) + end, + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Test doConfigure for OnOff Plug with Electrical Energy Measurement cluster is handled properly", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device_with_electrical_measurement_clusters.id, "doConfigure" }) + mock_device_with_electrical_measurement_clusters:expect_metadata_update({ profile = "plug-energy-powerConsumption" }) + mock_device_with_electrical_measurement_clusters:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + end, + { + test_init = function() + test.disable_startup_messages() + test.mock_device.add_test_device(mock_device_with_electrical_measurement_clusters) + mock_device_with_electrical_measurement_clusters:set_field(switch_fields.ELECTRICAL_TAGS .. "_2", "-energy-powerConsumption", {persist = true}) + mock_device_with_electrical_measurement_clusters:set_field(switch_fields.profiling_data.BATTERY_SUPPORT, false, {persist = true}) + mock_device_with_electrical_measurement_clusters:set_field(switch_fields.profiling_data.POWER_TOPOLOGY, false, {persist = true}) + end, + min_api_version = 17 + } +) + +test.run_registered_tests()