Skip to content

Commit 352d0f9

Browse files
authored
CHAD-19552: Support battery powered cameras (#3314)
1 parent 4decf46 commit 352d0f9

7 files changed

Lines changed: 276 additions & 0 deletions

File tree

‎drivers/SmartThings/matter-switch/profiles/camera.yml‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ components:
5050
- id: vision.clipAnalysis
5151
version: 1
5252
optional: true
53+
- id: battery
54+
version: 1
55+
optional: true
56+
- id: batteryLevel
57+
version: 1
58+
optional: true
59+
- id: chargingState
60+
version: 1
61+
optional: true
5362
- id: firmwareUpdate
5463
version: 1
5564
- id: refresh
@@ -145,6 +154,15 @@ deviceConfig:
145154
- component: main
146155
capability: cameraPrivacyMode
147156
version: 1
157+
- component: main
158+
capability: battery
159+
version: 1
160+
- component: main
161+
capability: batteryLevel
162+
version: 1
163+
- component: main
164+
capability: chargingState
165+
version: 1
148166
actions:
149167
- component: main
150168
capability: videoCapture2

‎drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,32 @@ function CameraAttributeHandlers.camera_av_stream_management_attribute_list_hand
487487
camera_cfg.reconcile_profile_and_capabilities(device)
488488
end
489489

490+
function CameraAttributeHandlers.power_source_attribute_list_handler(driver, device, ib, response)
491+
local percent_present, level_present = false, false
492+
for _, attr in ipairs(ib.data.elements or {}) do
493+
if attr.value == clusters.PowerSource.attributes.BatPercentRemaining.ID then
494+
percent_present = true
495+
elseif attr.value == clusters.PowerSource.attributes.BatChargeLevel.ID then
496+
level_present = true
497+
end
498+
end
499+
-- store support per endpoint so that a report from one PowerSource endpoint (e.g. wired) cannot
500+
-- overwrite what another endpoint (e.g. battery) reported
501+
local support_by_ep = device:get_field(camera_fields.BATTERY_SUPPORT_BY_ENDPOINT) or {}
502+
local ep_key = tostring(ib.endpoint_id)
503+
local previous = support_by_ep[ep_key] or {}
504+
if (previous.percent or false) ~= percent_present or (previous.level or false) ~= level_present then
505+
support_by_ep[ep_key] = { percent = percent_present, level = level_present }
506+
device:set_field(camera_fields.BATTERY_SUPPORT_BY_ENDPOINT, support_by_ep, {persist = true})
507+
camera_cfg.reconcile_profile_and_capabilities(device)
508+
end
509+
end
510+
511+
function CameraAttributeHandlers.bat_charge_state_handler(driver, device, ib, response)
512+
local state = camera_fields.BAT_CHARGE_STATE_MAP[ib.data.value] or "error"
513+
device:emit_event_for_endpoint(ib, capabilities.chargingState.chargingState(state))
514+
end
515+
490516
function CameraAttributeHandlers.camera_feature_map_handler(driver, device, ib, response)
491517
camera_cfg.reconcile_profile_and_capabilities(device)
492518
end

‎drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/device_configuration.lua‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,22 @@ function CameraDeviceConfiguration.match_profile(device)
330330
table.insert(status_led_component_capabilities, capabilities.mode.ID)
331331
end
332332

333+
-- support is aggregated across all PowerSource endpoints. battery percentage is preferred;
334+
-- batteryLevel is only used when no endpoint supports percentage
335+
local percent_supported, level_supported = false, false
336+
for _, support in pairs(device:get_field(camera_fields.BATTERY_SUPPORT_BY_ENDPOINT) or {}) do
337+
percent_supported = percent_supported or support.percent == true
338+
level_supported = level_supported or support.level == true
339+
end
340+
if percent_supported then
341+
table.insert(main_component_capabilities, capabilities.battery.ID)
342+
elseif level_supported then
343+
table.insert(main_component_capabilities, capabilities.batteryLevel.ID)
344+
end
345+
if #device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.RECHARGEABLE}) > 0 then
346+
table.insert(main_component_capabilities, capabilities.chargingState.ID)
347+
end
348+
333349
table.insert(optional_supported_component_capabilities, {camera_fields.profile_components.main, main_component_capabilities})
334350
if #status_led_component_capabilities > 0 then
335351
table.insert(optional_supported_component_capabilities, {camera_fields.profile_components.statusLed, status_led_component_capabilities})

‎drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/fields.lua‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ CameraFields.DPTZ_VIEWPORTS = "__dptz_viewports"
1717
CameraFields.STATUS_LIGHT_ENABLED_PRESENT = "__status_light_enabled_present"
1818
CameraFields.STATUS_LIGHT_BRIGHTNESS_PRESENT = "__status_light_brightness_present"
1919
CameraFields.HARD_PRIVACY_MODE_PRESENT = "__hard_privacy_mode_present"
20+
-- table keyed by endpoint id of { percent = bool, level = bool },
21+
-- recording which battery attributes each PowerSource endpoint supports
22+
CameraFields.BATTERY_SUPPORT_BY_ENDPOINT = "__battery_support_by_endpoint"
23+
24+
CameraFields.BAT_CHARGE_STATE_MAP = {
25+
[clusters.PowerSource.types.BatChargeStateEnum.IS_CHARGING] = "charging",
26+
[clusters.PowerSource.types.BatChargeStateEnum.IS_AT_FULL_CHARGE] = "fullyCharged",
27+
[clusters.PowerSource.types.BatChargeStateEnum.IS_NOT_CHARGING] = "stopped",
28+
}
2029

2130
CameraFields.CameraAVSMFeatureMapAttr = { ID = 0xFFFC, cluster = clusters.CameraAvStreamManagement.ID }
2231
CameraFields.CameraAVSULMFeatureMapAttr = { ID = 0xFFFC, cluster = clusters.CameraAvSettingsUserLevelManagement.ID }

‎drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/utils.lua‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,15 @@ function CameraUtils.subscribe(device)
273273
[capabilities.motionSensor.ID] = {
274274
clusters.OccupancySensing.attributes.Occupancy
275275
},
276+
[capabilities.battery.ID] = {
277+
clusters.PowerSource.attributes.BatPercentRemaining
278+
},
279+
[capabilities.batteryLevel.ID] = {
280+
clusters.PowerSource.attributes.BatChargeLevel
281+
},
282+
[capabilities.chargingState.ID] = {
283+
clusters.PowerSource.attributes.BatChargeState
284+
},
276285
[capabilities.switchLevel.ID] = {
277286
clusters.LevelControl.attributes.CurrentLevel,
278287
clusters.LevelControl.attributes.MaxLevel,
@@ -324,6 +333,10 @@ function CameraUtils.subscribe(device)
324333
table.insert(additional_attributes, camera_fields.ZoneManagementFeatureMapAttr)
325334
end
326335

336+
if #device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) > 0 then
337+
table.insert(additional_attributes, clusters.PowerSource.attributes.AttributeList)
338+
end
339+
327340
for _, endpoint_info in ipairs(device.endpoints) do
328341
local checked_device = switch_utils.find_child(device, endpoint_info.endpoint_id) or device
329342
if not devices_seen[checked_device.id] then

‎drivers/SmartThings/matter-switch/src/sub_drivers/camera/init.lua‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,10 @@ local camera_handler = {
123123
[clusters.ZoneManagement.attributes.Sensitivity.ID] = attribute_handlers.sensitivity_handler,
124124
[camera_fields.ZoneManagementFeatureMapAttr.ID] = attribute_handlers.camera_feature_map_handler
125125
},
126+
[clusters.PowerSource.ID] = {
127+
[clusters.PowerSource.attributes.AttributeList.ID] = attribute_handlers.power_source_attribute_list_handler,
128+
[clusters.PowerSource.attributes.BatChargeState.ID] = attribute_handlers.bat_charge_state_handler
129+
},
126130
[clusters.Chime.ID] = {
127131
[clusters.Chime.attributes.InstalledChimeSounds.ID] = attribute_handlers.installed_chime_sounds_handler,
128132
[clusters.Chime.attributes.SelectedChime.ID] = attribute_handlers.selected_chime_handler

‎drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua‎

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4013,5 +4013,195 @@ test.register_coroutine_test(
40134013
}
40144014
)
40154015

4016+
-- Battery / batteryLevel / chargingState profile and handler tests. These use a fake device
4017+
-- (like the volume range tests above) so that PowerSource combinations can be varied freely.
4018+
local function build_fake_power_source_device(opts)
4019+
local fields_store = {}
4020+
if opts.percent ~= nil or opts.level ~= nil then
4021+
fields_store[camera_fields.BATTERY_SUPPORT_BY_ENDPOINT] = { ["0"] = { percent = opts.percent, level = opts.level } }
4022+
end
4023+
return {
4024+
profile = { components = {} },
4025+
endpoints = {},
4026+
emitted_events = {},
4027+
get_field = function(_, field) return fields_store[field] end,
4028+
set_field = function(_, field, value) fields_store[field] = value end,
4029+
get_endpoints = function(_, cluster_id, filter)
4030+
if cluster_id == clusters.PowerSource.ID and filter and filter.feature_bitmap ==
4031+
clusters.PowerSource.types.PowerSourceFeature.RECHARGEABLE and opts.rechargeable then
4032+
return { 0 }
4033+
end
4034+
return {}
4035+
end,
4036+
supports_capability = function() return false end,
4037+
try_update_metadata = function(self, metadata) self.updated_metadata = metadata end,
4038+
emit_event_for_endpoint = function(self, _, event) table.insert(self.emitted_events, event) end,
4039+
}
4040+
end
4041+
4042+
local function power_source_attribute_list_ib(attribute_ids, endpoint_id)
4043+
local elements = {}
4044+
for _, id in ipairs(attribute_ids) do table.insert(elements, { value = id }) end
4045+
return { endpoint_id = endpoint_id or 0, data = { elements = elements } }
4046+
end
4047+
4048+
local BAT_PERCENT_ID = clusters.PowerSource.attributes.BatPercentRemaining.ID
4049+
local BAT_LEVEL_ID = clusters.PowerSource.attributes.BatChargeLevel.ID
4050+
local BAT_STATE_ID = clusters.PowerSource.attributes.BatChargeState.ID
4051+
4052+
local power_source_profile_cases = {
4053+
{ name = "neither battery attribute nor RECHG", opts = {}, battery = false, batteryLevel = false, chargingState = false },
4054+
{ name = "battery percentage only", opts = { percent = true }, battery = true, batteryLevel = false, chargingState = false },
4055+
{ name = "battery level only", opts = { level = true }, battery = false, batteryLevel = true, chargingState = false },
4056+
{ name = "percentage and level (percentage preferred)", opts = { percent = true, level = true }, battery = true, batteryLevel = false, chargingState = false },
4057+
{ name = "RECHG only", opts = { rechargeable = true }, battery = false, batteryLevel = false, chargingState = true },
4058+
{ name = "percentage and RECHG", opts = { percent = true, rechargeable = true }, battery = true, batteryLevel = false, chargingState = true },
4059+
{ name = "level and RECHG", opts = { level = true, rechargeable = true }, battery = false, batteryLevel = true, chargingState = true },
4060+
{ name = "percentage, level and RECHG", opts = { percent = true, level = true, rechargeable = true }, battery = true, batteryLevel = false, chargingState = true },
4061+
}
4062+
4063+
for _, case in ipairs(power_source_profile_cases) do
4064+
test.register_coroutine_test(
4065+
"match_profile should set battery capabilities correctly for: " .. case.name,
4066+
function()
4067+
local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration"
4068+
local fake_device = build_fake_power_source_device(case.opts)
4069+
4070+
camera_cfg.match_profile(fake_device)
4071+
4072+
assert(fake_device.updated_metadata ~= nil, "profile update should be requested")
4073+
local main_capabilities = component_capabilities(fake_device.updated_metadata, "main")
4074+
assert(list_contains(main_capabilities, capabilities.battery.ID) == case.battery, "unexpected battery presence")
4075+
assert(list_contains(main_capabilities, capabilities.batteryLevel.ID) == case.batteryLevel, "unexpected batteryLevel presence")
4076+
assert(list_contains(main_capabilities, capabilities.chargingState.ID) == case.chargingState, "unexpected chargingState presence")
4077+
end,
4078+
{
4079+
min_api_version = 14
4080+
}
4081+
)
4082+
end
4083+
4084+
local attribute_list_cases = {
4085+
{ name = "percentage then level", ids = { BAT_PERCENT_ID, BAT_LEVEL_ID }, percent = true, level = true },
4086+
{ name = "level then percentage (out of order)", ids = { 0x00, BAT_LEVEL_ID, 0x01, BAT_PERCENT_ID }, percent = true, level = true },
4087+
{ name = "percentage only", ids = { 0x00, BAT_PERCENT_ID, BAT_STATE_ID }, percent = true, level = false },
4088+
{ name = "level only", ids = { 0x00, BAT_LEVEL_ID, BAT_STATE_ID }, percent = false, level = true },
4089+
{ name = "neither", ids = { 0x00, 0x01, BAT_STATE_ID }, percent = false, level = false },
4090+
}
4091+
4092+
for _, case in ipairs(attribute_list_cases) do
4093+
test.register_coroutine_test(
4094+
"PowerSource AttributeList handler should record battery attribute support for: " .. case.name,
4095+
function()
4096+
local attribute_handlers = require "sub_drivers.camera.camera_handlers.attribute_handlers"
4097+
local fake_device = build_fake_power_source_device({})
4098+
4099+
attribute_handlers.power_source_attribute_list_handler({}, fake_device, power_source_attribute_list_ib(case.ids))
4100+
4101+
local stored = (fake_device:get_field(camera_fields.BATTERY_SUPPORT_BY_ENDPOINT) or {})["0"] or {}
4102+
assert((stored.percent or false) == case.percent, "unexpected percentage support")
4103+
assert((stored.level or false) == case.level, "unexpected level support")
4104+
if case.percent or case.level then
4105+
local main_capabilities = component_capabilities(fake_device.updated_metadata, "main")
4106+
assert(list_contains(main_capabilities, capabilities.battery.ID) == case.percent, "battery should follow percentage support")
4107+
assert(list_contains(main_capabilities, capabilities.batteryLevel.ID) == (case.level and not case.percent), "batteryLevel only used without percentage")
4108+
end
4109+
end,
4110+
{
4111+
min_api_version = 14
4112+
}
4113+
)
4114+
end
4115+
4116+
test.register_coroutine_test(
4117+
"PowerSource AttributeList handler should not request a profile update when support is unchanged",
4118+
function()
4119+
local attribute_handlers = require "sub_drivers.camera.camera_handlers.attribute_handlers"
4120+
local fake_device = build_fake_power_source_device({ percent = true, level = false })
4121+
4122+
attribute_handlers.power_source_attribute_list_handler({}, fake_device, power_source_attribute_list_ib({ BAT_PERCENT_ID }))
4123+
4124+
assert(fake_device.updated_metadata == nil, "no profile update expected when support is unchanged")
4125+
end,
4126+
{
4127+
min_api_version = 14
4128+
}
4129+
)
4130+
4131+
local multi_endpoint_cases = {
4132+
{ name = "battery endpoint reports before wired endpoint", order = { 2, 1 } },
4133+
{ name = "wired endpoint reports before battery endpoint", order = { 1, 2 } },
4134+
}
4135+
4136+
for _, case in ipairs(multi_endpoint_cases) do
4137+
test.register_coroutine_test(
4138+
"PowerSource AttributeList reports from multiple endpoints should not overwrite each other: " .. case.name,
4139+
function()
4140+
local attribute_handlers = require "sub_drivers.camera.camera_handlers.attribute_handlers"
4141+
local fake_device = build_fake_power_source_device({})
4142+
local reports = {
4143+
[1] = power_source_attribute_list_ib({ 0x00, 0x01 }, 1), -- wired: no battery attributes
4144+
[2] = power_source_attribute_list_ib({ 0x00, BAT_LEVEL_ID, BAT_PERCENT_ID }, 2), -- battery
4145+
}
4146+
4147+
for _, ep in ipairs(case.order) do
4148+
attribute_handlers.power_source_attribute_list_handler({}, fake_device, reports[ep])
4149+
end
4150+
4151+
-- re-evaluate the profile from the stored state, as any later reconcile would
4152+
fake_device.updated_metadata = nil
4153+
require("sub_drivers.camera.camera_utils.device_configuration").match_profile(fake_device)
4154+
local main_capabilities = component_capabilities(fake_device.updated_metadata, "main")
4155+
assert(list_contains(main_capabilities, capabilities.battery.ID), "battery should be supported regardless of report order")
4156+
assert(not list_contains(main_capabilities, capabilities.batteryLevel.ID), "batteryLevel should not be used when percentage is supported")
4157+
end,
4158+
{
4159+
min_api_version = 14
4160+
}
4161+
)
4162+
end
4163+
4164+
test.register_coroutine_test(
4165+
"PowerSource AttributeList should clear support when the same endpoint later drops the attributes",
4166+
function()
4167+
local attribute_handlers = require "sub_drivers.camera.camera_handlers.attribute_handlers"
4168+
local fake_device = build_fake_power_source_device({})
4169+
4170+
attribute_handlers.power_source_attribute_list_handler({}, fake_device, power_source_attribute_list_ib({ BAT_PERCENT_ID }, 2))
4171+
attribute_handlers.power_source_attribute_list_handler({}, fake_device, power_source_attribute_list_ib({ 0x00 }, 2))
4172+
4173+
local stored = fake_device:get_field(camera_fields.BATTERY_SUPPORT_BY_ENDPOINT)["2"]
4174+
assert(stored.percent == false and stored.level == false, "support should be cleared for that endpoint")
4175+
end,
4176+
{
4177+
min_api_version = 14
4178+
}
4179+
)
4180+
4181+
local charge_state_cases = {
4182+
{ value = clusters.PowerSource.types.BatChargeStateEnum.IS_CHARGING, expected = "charging" },
4183+
{ value = clusters.PowerSource.types.BatChargeStateEnum.IS_AT_FULL_CHARGE, expected = "fullyCharged" },
4184+
{ value = clusters.PowerSource.types.BatChargeStateEnum.IS_NOT_CHARGING, expected = "stopped" },
4185+
{ value = clusters.PowerSource.types.BatChargeStateEnum.UNKNOWN, expected = "error" },
4186+
}
4187+
4188+
for _, case in ipairs(charge_state_cases) do
4189+
test.register_coroutine_test(
4190+
"BatChargeState " .. case.expected .. " should emit chargingState " .. case.expected,
4191+
function()
4192+
local attribute_handlers = require "sub_drivers.camera.camera_handlers.attribute_handlers"
4193+
local fake_device = build_fake_power_source_device({ rechargeable = true })
4194+
4195+
attribute_handlers.bat_charge_state_handler({}, fake_device, { endpoint_id = 0, data = { value = case.value } })
4196+
4197+
assert(#fake_device.emitted_events == 1, "exactly one event expected")
4198+
assert(fake_device.emitted_events[1].value.value == case.expected, "unexpected chargingState value")
4199+
end,
4200+
{
4201+
min_api_version = 14
4202+
}
4203+
)
4204+
end
4205+
40164206
-- run the tests
40174207
test.run_registered_tests()

0 commit comments

Comments
 (0)