From 56119fd810829a65e2fe3aeffd08c7cf9919dca3 Mon Sep 17 00:00:00 2001 From: Abdul Samad Date: Wed, 28 Oct 2026 19:02:47 -0500 Subject: [PATCH 1/2] CHAD-19552: Support battery powered cameras --- .../matter-switch/profiles/camera.yml | 18 +++ .../camera_handlers/attribute_handlers.lua | 26 ++++ .../camera_utils/device_configuration.lua | 10 ++ .../camera/camera_utils/fields.lua | 8 + .../sub_drivers/camera/camera_utils/utils.lua | 13 ++ .../src/sub_drivers/camera/init.lua | 4 + .../src/test/test_matter_camera.lua | 138 ++++++++++++++++++ 7 files changed, 217 insertions(+) diff --git a/drivers/SmartThings/matter-switch/profiles/camera.yml b/drivers/SmartThings/matter-switch/profiles/camera.yml index 1f911a9e10..757374afbb 100644 --- a/drivers/SmartThings/matter-switch/profiles/camera.yml +++ b/drivers/SmartThings/matter-switch/profiles/camera.yml @@ -50,6 +50,15 @@ components: - id: vision.clipAnalysis version: 1 optional: true + - id: battery + version: 1 + optional: true + - id: batteryLevel + version: 1 + optional: true + - id: chargingState + version: 1 + optional: true - id: firmwareUpdate version: 1 - id: refresh @@ -142,6 +151,15 @@ deviceConfig: - component: main capability: motionSensor version: 1 + - component: main + capability: battery + version: 1 + - component: main + capability: batteryLevel + version: 1 + - component: main + capability: chargingState + version: 1 actions: - component: main capability: videoCapture2 diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua index 0b08bf27b0..0ee06633e3 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua @@ -487,6 +487,32 @@ function CameraAttributeHandlers.camera_av_stream_management_attribute_list_hand camera_cfg.reconcile_profile_and_capabilities(device) end +function CameraAttributeHandlers.power_source_attribute_list_handler(driver, device, ib, response) + local percent_present, level_present = false, false + for _, attr in ipairs(ib.data.elements or {}) do + if attr.value == clusters.PowerSource.attributes.BatPercentRemaining.ID then + percent_present = true + elseif attr.value == clusters.PowerSource.attributes.BatChargeLevel.ID then + level_present = true + end + end + if (device:get_field(camera_fields.BAT_PERCENT_REMAINING_PRESENT) or false) ~= percent_present or + (device:get_field(camera_fields.BAT_CHARGE_LEVEL_PRESENT) or false) ~= level_present then + device:set_field(camera_fields.BAT_PERCENT_REMAINING_PRESENT, percent_present, {persist = true}) + device:set_field(camera_fields.BAT_CHARGE_LEVEL_PRESENT, level_present, {persist = true}) + camera_cfg.reconcile_profile_and_capabilities(device) + end +end + +function CameraAttributeHandlers.bat_charge_state_handler(driver, device, ib, response) + local state = camera_fields.BAT_CHARGE_STATE_MAP[ib.data.value] + if state then + device:emit_event_for_endpoint(ib, capabilities.chargingState.chargingState[state]()) + else + device:emit_event_for_endpoint(ib, capabilities.chargingState.chargingState.error()) + end +end + function CameraAttributeHandlers.camera_feature_map_handler(driver, device, ib, response) camera_cfg.reconcile_profile_and_capabilities(device) end 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 8a5db7c5b5..e72d494129 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 @@ -330,6 +330,16 @@ function CameraDeviceConfiguration.match_profile(device) table.insert(status_led_component_capabilities, capabilities.mode.ID) end + -- battery percentage is preferred; batteryLevel is only used when percentage is not supported + if device:get_field(camera_fields.BAT_PERCENT_REMAINING_PRESENT) then + table.insert(main_component_capabilities, capabilities.battery.ID) + elseif device:get_field(camera_fields.BAT_CHARGE_LEVEL_PRESENT) then + table.insert(main_component_capabilities, capabilities.batteryLevel.ID) + end + if #device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.RECHARGEABLE}) > 0 then + table.insert(main_component_capabilities, capabilities.chargingState.ID) + end + table.insert(optional_supported_component_capabilities, {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}) diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/fields.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/fields.lua index edb4deb125..aa4417a8c1 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/fields.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/fields.lua @@ -17,6 +17,14 @@ CameraFields.DPTZ_VIEWPORTS = "__dptz_viewports" CameraFields.STATUS_LIGHT_ENABLED_PRESENT = "__status_light_enabled_present" CameraFields.STATUS_LIGHT_BRIGHTNESS_PRESENT = "__status_light_brightness_present" CameraFields.HARD_PRIVACY_MODE_PRESENT = "__hard_privacy_mode_present" +CameraFields.BAT_PERCENT_REMAINING_PRESENT = "__bat_percent_remaining_present" +CameraFields.BAT_CHARGE_LEVEL_PRESENT = "__bat_charge_level_present" + +CameraFields.BAT_CHARGE_STATE_MAP = { + [clusters.PowerSource.types.BatChargeStateEnum.IS_CHARGING] = "charging", + [clusters.PowerSource.types.BatChargeStateEnum.IS_AT_FULL_CHARGE] = "fullyCharged", + [clusters.PowerSource.types.BatChargeStateEnum.IS_NOT_CHARGING] = "stopped", +} CameraFields.CameraAVSMFeatureMapAttr = { ID = 0xFFFC, cluster = clusters.CameraAvStreamManagement.ID } CameraFields.CameraAVSULMFeatureMapAttr = { ID = 0xFFFC, cluster = clusters.CameraAvSettingsUserLevelManagement.ID } diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/utils.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/utils.lua index 5cc7dee498..0590990f0c 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/utils.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/utils.lua @@ -273,6 +273,15 @@ function CameraUtils.subscribe(device) [capabilities.motionSensor.ID] = { clusters.OccupancySensing.attributes.Occupancy }, + [capabilities.battery.ID] = { + clusters.PowerSource.attributes.BatPercentRemaining + }, + [capabilities.batteryLevel.ID] = { + clusters.PowerSource.attributes.BatChargeLevel + }, + [capabilities.chargingState.ID] = { + clusters.PowerSource.attributes.BatChargeState + }, [capabilities.switchLevel.ID] = { clusters.LevelControl.attributes.CurrentLevel, clusters.LevelControl.attributes.MaxLevel, @@ -324,6 +333,10 @@ function CameraUtils.subscribe(device) table.insert(additional_attributes, camera_fields.ZoneManagementFeatureMapAttr) end + if #device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) > 0 then + table.insert(additional_attributes, clusters.PowerSource.attributes.AttributeList) + end + for _, endpoint_info in ipairs(device.endpoints) do local checked_device = switch_utils.find_child(device, endpoint_info.endpoint_id) or device if not devices_seen[checked_device.id] then diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/init.lua index 2aa698a08a..fd6a8ac8f1 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/init.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/init.lua @@ -123,6 +123,10 @@ local camera_handler = { [clusters.ZoneManagement.attributes.Sensitivity.ID] = attribute_handlers.sensitivity_handler, [camera_fields.ZoneManagementFeatureMapAttr.ID] = attribute_handlers.camera_feature_map_handler }, + [clusters.PowerSource.ID] = { + [clusters.PowerSource.attributes.AttributeList.ID] = attribute_handlers.power_source_attribute_list_handler, + [clusters.PowerSource.attributes.BatChargeState.ID] = attribute_handlers.bat_charge_state_handler + }, [clusters.Chime.ID] = { [clusters.Chime.attributes.InstalledChimeSounds.ID] = attribute_handlers.installed_chime_sounds_handler, [clusters.Chime.attributes.SelectedChime.ID] = attribute_handlers.selected_chime_handler diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua index 6f5b1251eb..bd5b05cf48 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua @@ -4013,5 +4013,143 @@ test.register_coroutine_test( } ) +-- Battery / batteryLevel / chargingState profile and handler tests. These use a fake device +-- (like the volume range tests above) so that PowerSource combinations can be varied freely. +local function build_fake_power_source_device(opts) + local fields_store = {} + if opts.percent ~= nil then fields_store[camera_fields.BAT_PERCENT_REMAINING_PRESENT] = opts.percent end + if opts.level ~= nil then fields_store[camera_fields.BAT_CHARGE_LEVEL_PRESENT] = opts.level end + return { + profile = { components = {} }, + endpoints = {}, + emitted_events = {}, + get_field = function(_, field) return fields_store[field] end, + set_field = function(_, field, value) fields_store[field] = value end, + get_endpoints = function(_, cluster_id, filter) + if cluster_id == clusters.PowerSource.ID and filter and filter.feature_bitmap == + clusters.PowerSource.types.PowerSourceFeature.RECHARGEABLE and opts.rechargeable then + return { 0 } + end + return {} + end, + supports_capability = function() return false end, + try_update_metadata = function(self, metadata) self.updated_metadata = metadata end, + emit_event_for_endpoint = function(self, _, event) table.insert(self.emitted_events, event) end, + } +end + +local function power_source_attribute_list_ib(attribute_ids) + local elements = {} + for _, id in ipairs(attribute_ids) do table.insert(elements, { value = id }) end + return { endpoint_id = 0, data = { elements = elements } } +end + +local BAT_PERCENT_ID = clusters.PowerSource.attributes.BatPercentRemaining.ID +local BAT_LEVEL_ID = clusters.PowerSource.attributes.BatChargeLevel.ID +local BAT_STATE_ID = clusters.PowerSource.attributes.BatChargeState.ID + +local power_source_profile_cases = { + { name = "neither battery attribute nor RECHG", opts = {}, battery = false, batteryLevel = false, chargingState = false }, + { name = "battery percentage only", opts = { percent = true }, battery = true, batteryLevel = false, chargingState = false }, + { name = "battery level only", opts = { level = true }, battery = false, batteryLevel = true, chargingState = false }, + { name = "percentage and level (percentage preferred)", opts = { percent = true, level = true }, battery = true, batteryLevel = false, chargingState = false }, + { name = "RECHG only", opts = { rechargeable = true }, battery = false, batteryLevel = false, chargingState = true }, + { name = "percentage and RECHG", opts = { percent = true, rechargeable = true }, battery = true, batteryLevel = false, chargingState = true }, + { name = "level and RECHG", opts = { level = true, rechargeable = true }, battery = false, batteryLevel = true, chargingState = true }, + { name = "percentage, level and RECHG", opts = { percent = true, level = true, rechargeable = true }, battery = true, batteryLevel = false, chargingState = true }, +} + +for _, case in ipairs(power_source_profile_cases) do + test.register_coroutine_test( + "match_profile should set battery capabilities correctly for: " .. case.name, + function() + local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" + local fake_device = build_fake_power_source_device(case.opts) + + camera_cfg.match_profile(fake_device) + + assert(fake_device.updated_metadata ~= nil, "profile update should be requested") + local main_capabilities = component_capabilities(fake_device.updated_metadata, "main") + assert(list_contains(main_capabilities, capabilities.battery.ID) == case.battery, "unexpected battery presence") + assert(list_contains(main_capabilities, capabilities.batteryLevel.ID) == case.batteryLevel, "unexpected batteryLevel presence") + assert(list_contains(main_capabilities, capabilities.chargingState.ID) == case.chargingState, "unexpected chargingState presence") + end, + { + min_api_version = 14 + } + ) +end + +local attribute_list_cases = { + { name = "percentage then level", ids = { BAT_PERCENT_ID, BAT_LEVEL_ID }, percent = true, level = true }, + { name = "level then percentage (out of order)", ids = { 0x00, BAT_LEVEL_ID, 0x01, BAT_PERCENT_ID }, percent = true, level = true }, + { name = "percentage only", ids = { 0x00, BAT_PERCENT_ID, BAT_STATE_ID }, percent = true, level = false }, + { name = "level only", ids = { 0x00, BAT_LEVEL_ID, BAT_STATE_ID }, percent = false, level = true }, + { name = "neither", ids = { 0x00, 0x01, BAT_STATE_ID }, percent = false, level = false }, +} + +for _, case in ipairs(attribute_list_cases) do + test.register_coroutine_test( + "PowerSource AttributeList handler should record battery attribute support for: " .. case.name, + function() + local attribute_handlers = require "sub_drivers.camera.camera_handlers.attribute_handlers" + local fake_device = build_fake_power_source_device({}) + + attribute_handlers.power_source_attribute_list_handler({}, fake_device, power_source_attribute_list_ib(case.ids)) + + assert((fake_device:get_field(camera_fields.BAT_PERCENT_REMAINING_PRESENT) or false) == case.percent, "unexpected percentage support") + assert((fake_device:get_field(camera_fields.BAT_CHARGE_LEVEL_PRESENT) or false) == case.level, "unexpected level support") + if case.percent or case.level then + local main_capabilities = component_capabilities(fake_device.updated_metadata, "main") + assert(list_contains(main_capabilities, capabilities.battery.ID) == case.percent, "battery should follow percentage support") + assert(list_contains(main_capabilities, capabilities.batteryLevel.ID) == (case.level and not case.percent), "batteryLevel only used without percentage") + end + end, + { + min_api_version = 14 + } + ) +end + +test.register_coroutine_test( + "PowerSource AttributeList handler should not request a profile update when support is unchanged", + function() + local attribute_handlers = require "sub_drivers.camera.camera_handlers.attribute_handlers" + local fake_device = build_fake_power_source_device({ percent = true, level = false }) + + attribute_handlers.power_source_attribute_list_handler({}, fake_device, power_source_attribute_list_ib({ BAT_PERCENT_ID })) + + assert(fake_device.updated_metadata == nil, "no profile update expected when support is unchanged") + end, + { + min_api_version = 14 + } +) + +local charge_state_cases = { + { value = clusters.PowerSource.types.BatChargeStateEnum.IS_CHARGING, expected = "charging" }, + { value = clusters.PowerSource.types.BatChargeStateEnum.IS_AT_FULL_CHARGE, expected = "fullyCharged" }, + { value = clusters.PowerSource.types.BatChargeStateEnum.IS_NOT_CHARGING, expected = "stopped" }, + { value = clusters.PowerSource.types.BatChargeStateEnum.UNKNOWN, expected = "error" }, +} + +for _, case in ipairs(charge_state_cases) do + test.register_coroutine_test( + "BatChargeState " .. case.expected .. " should emit chargingState " .. case.expected, + function() + local attribute_handlers = require "sub_drivers.camera.camera_handlers.attribute_handlers" + local fake_device = build_fake_power_source_device({ rechargeable = true }) + + attribute_handlers.bat_charge_state_handler({}, fake_device, { endpoint_id = 0, data = { value = case.value } }) + + assert(#fake_device.emitted_events == 1, "exactly one event expected") + assert(fake_device.emitted_events[1].value.value == case.expected, "unexpected chargingState value") + end, + { + min_api_version = 14 + } + ) +end + -- run the tests test.run_registered_tests() From 7c748c79d8d7d3612ab79c8e42b3582df911d6be Mon Sep 17 00:00:00 2001 From: Abdul Samad Date: Wed, 28 Oct 2026 20:20:58 -0500 Subject: [PATCH 2/2] Handle cameras with multiple power sources --- .../camera_handlers/attribute_handlers.lua | 12 ++-- .../camera_utils/device_configuration.lua | 12 +++- .../camera/camera_utils/fields.lua | 5 +- .../src/test/test_matter_camera.lua | 64 +++++++++++++++++-- 4 files changed, 78 insertions(+), 15 deletions(-) diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua index 0ee06633e3..a6b1cbd7bd 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua @@ -496,10 +496,14 @@ function CameraAttributeHandlers.power_source_attribute_list_handler(driver, dev level_present = true end end - if (device:get_field(camera_fields.BAT_PERCENT_REMAINING_PRESENT) or false) ~= percent_present or - (device:get_field(camera_fields.BAT_CHARGE_LEVEL_PRESENT) or false) ~= level_present then - device:set_field(camera_fields.BAT_PERCENT_REMAINING_PRESENT, percent_present, {persist = true}) - device:set_field(camera_fields.BAT_CHARGE_LEVEL_PRESENT, level_present, {persist = true}) + -- store support per endpoint so that a report from one PowerSource endpoint (e.g. wired) cannot + -- overwrite what another endpoint (e.g. battery) reported + local support_by_ep = device:get_field(camera_fields.BATTERY_SUPPORT_BY_ENDPOINT) or {} + local ep_key = tostring(ib.endpoint_id) + local previous = support_by_ep[ep_key] or {} + if (previous.percent or false) ~= percent_present or (previous.level or false) ~= level_present then + support_by_ep[ep_key] = { percent = percent_present, level = level_present } + device:set_field(camera_fields.BATTERY_SUPPORT_BY_ENDPOINT, support_by_ep, {persist = true}) camera_cfg.reconcile_profile_and_capabilities(device) end end 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 e72d494129..be6257a618 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 @@ -330,10 +330,16 @@ function CameraDeviceConfiguration.match_profile(device) table.insert(status_led_component_capabilities, capabilities.mode.ID) end - -- battery percentage is preferred; batteryLevel is only used when percentage is not supported - if device:get_field(camera_fields.BAT_PERCENT_REMAINING_PRESENT) then + -- support is aggregated across all PowerSource endpoints. battery percentage is preferred; + -- batteryLevel is only used when no endpoint supports percentage + local percent_supported, level_supported = false, false + for _, support in pairs(device:get_field(camera_fields.BATTERY_SUPPORT_BY_ENDPOINT) or {}) do + percent_supported = percent_supported or support.percent == true + level_supported = level_supported or support.level == true + end + if percent_supported then table.insert(main_component_capabilities, capabilities.battery.ID) - elseif device:get_field(camera_fields.BAT_CHARGE_LEVEL_PRESENT) then + elseif level_supported then table.insert(main_component_capabilities, capabilities.batteryLevel.ID) end if #device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.RECHARGEABLE}) > 0 then diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/fields.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/fields.lua index aa4417a8c1..3ea2d13348 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/fields.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/fields.lua @@ -17,8 +17,9 @@ CameraFields.DPTZ_VIEWPORTS = "__dptz_viewports" CameraFields.STATUS_LIGHT_ENABLED_PRESENT = "__status_light_enabled_present" CameraFields.STATUS_LIGHT_BRIGHTNESS_PRESENT = "__status_light_brightness_present" CameraFields.HARD_PRIVACY_MODE_PRESENT = "__hard_privacy_mode_present" -CameraFields.BAT_PERCENT_REMAINING_PRESENT = "__bat_percent_remaining_present" -CameraFields.BAT_CHARGE_LEVEL_PRESENT = "__bat_charge_level_present" +-- table keyed by endpoint id of { percent = bool, level = bool }, +-- recording which battery attributes each PowerSource endpoint supports +CameraFields.BATTERY_SUPPORT_BY_ENDPOINT = "__battery_support_by_endpoint" CameraFields.BAT_CHARGE_STATE_MAP = { [clusters.PowerSource.types.BatChargeStateEnum.IS_CHARGING] = "charging", diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua index bd5b05cf48..5a7fc793e5 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua @@ -4017,8 +4017,9 @@ test.register_coroutine_test( -- (like the volume range tests above) so that PowerSource combinations can be varied freely. local function build_fake_power_source_device(opts) local fields_store = {} - if opts.percent ~= nil then fields_store[camera_fields.BAT_PERCENT_REMAINING_PRESENT] = opts.percent end - if opts.level ~= nil then fields_store[camera_fields.BAT_CHARGE_LEVEL_PRESENT] = opts.level end + if opts.percent ~= nil or opts.level ~= nil then + fields_store[camera_fields.BATTERY_SUPPORT_BY_ENDPOINT] = { ["0"] = { percent = opts.percent, level = opts.level } } + end return { profile = { components = {} }, endpoints = {}, @@ -4038,10 +4039,10 @@ local function build_fake_power_source_device(opts) } end -local function power_source_attribute_list_ib(attribute_ids) +local function power_source_attribute_list_ib(attribute_ids, endpoint_id) local elements = {} for _, id in ipairs(attribute_ids) do table.insert(elements, { value = id }) end - return { endpoint_id = 0, data = { elements = elements } } + return { endpoint_id = endpoint_id or 0, data = { elements = elements } } end local BAT_PERCENT_ID = clusters.PowerSource.attributes.BatPercentRemaining.ID @@ -4097,8 +4098,9 @@ for _, case in ipairs(attribute_list_cases) do attribute_handlers.power_source_attribute_list_handler({}, fake_device, power_source_attribute_list_ib(case.ids)) - assert((fake_device:get_field(camera_fields.BAT_PERCENT_REMAINING_PRESENT) or false) == case.percent, "unexpected percentage support") - assert((fake_device:get_field(camera_fields.BAT_CHARGE_LEVEL_PRESENT) or false) == case.level, "unexpected level support") + local stored = (fake_device:get_field(camera_fields.BATTERY_SUPPORT_BY_ENDPOINT) or {})["0"] or {} + assert((stored.percent or false) == case.percent, "unexpected percentage support") + assert((stored.level or false) == case.level, "unexpected level support") if case.percent or case.level then local main_capabilities = component_capabilities(fake_device.updated_metadata, "main") assert(list_contains(main_capabilities, capabilities.battery.ID) == case.percent, "battery should follow percentage support") @@ -4126,6 +4128,56 @@ test.register_coroutine_test( } ) +local multi_endpoint_cases = { + { name = "battery endpoint reports before wired endpoint", order = { 2, 1 } }, + { name = "wired endpoint reports before battery endpoint", order = { 1, 2 } }, +} + +for _, case in ipairs(multi_endpoint_cases) do + test.register_coroutine_test( + "PowerSource AttributeList reports from multiple endpoints should not overwrite each other: " .. case.name, + function() + local attribute_handlers = require "sub_drivers.camera.camera_handlers.attribute_handlers" + local fake_device = build_fake_power_source_device({}) + local reports = { + [1] = power_source_attribute_list_ib({ 0x00, 0x01 }, 1), -- wired: no battery attributes + [2] = power_source_attribute_list_ib({ 0x00, BAT_LEVEL_ID, BAT_PERCENT_ID }, 2), -- battery + } + + for _, ep in ipairs(case.order) do + attribute_handlers.power_source_attribute_list_handler({}, fake_device, reports[ep]) + end + + -- re-evaluate the profile from the stored state, as any later reconcile would + fake_device.updated_metadata = nil + require("sub_drivers.camera.camera_utils.device_configuration").match_profile(fake_device) + local main_capabilities = component_capabilities(fake_device.updated_metadata, "main") + assert(list_contains(main_capabilities, capabilities.battery.ID), "battery should be supported regardless of report order") + assert(not list_contains(main_capabilities, capabilities.batteryLevel.ID), "batteryLevel should not be used when percentage is supported") + end, + { + min_api_version = 14 + } + ) +end + +test.register_coroutine_test( + "PowerSource AttributeList should clear support when the same endpoint later drops the attributes", + function() + local attribute_handlers = require "sub_drivers.camera.camera_handlers.attribute_handlers" + local fake_device = build_fake_power_source_device({}) + + attribute_handlers.power_source_attribute_list_handler({}, fake_device, power_source_attribute_list_ib({ BAT_PERCENT_ID }, 2)) + attribute_handlers.power_source_attribute_list_handler({}, fake_device, power_source_attribute_list_ib({ 0x00 }, 2)) + + local stored = fake_device:get_field(camera_fields.BATTERY_SUPPORT_BY_ENDPOINT)["2"] + assert(stored.percent == false and stored.level == false, "support should be cleared for that endpoint") + end, + { + min_api_version = 14 + } +) + local charge_state_cases = { { value = clusters.PowerSource.types.BatChargeStateEnum.IS_CHARGING, expected = "charging" }, { value = clusters.PowerSource.types.BatChargeStateEnum.IS_AT_FULL_CHARGE, expected = "fullyCharged" },