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 075f9af600..0b08bf27b0 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 @@ -57,10 +57,14 @@ end function CameraAttributeHandlers.volume_level_handler(driver, device, ib, response) local component = device:endpoint_to_component(ib) - local max_volume = device:get_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. component) or camera_fields.ABS_VOL_MAX - local min_volume = device:get_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. component) or camera_fields.ABS_VOL_MIN - -- Convert from [min_volume, max_volume] to [0, 100] before emitting capability + local max_volume = camera_utils.get_field_for_component(device, camera_fields.MAX_VOLUME_LEVEL, component) or camera_fields.ABS_VOL_MAX + local min_volume = camera_utils.get_field_for_component(device, camera_fields.MIN_VOLUME_LEVEL, component) or camera_fields.ABS_VOL_MIN local limited_range = max_volume - min_volume + if limited_range <= 0 then + device:emit_event_for_endpoint(ib, capabilities.audioVolume.volume(0)) + return + end + -- Convert from [min_volume, max_volume] to [0, 100] before emitting capability local normalized_volume = utils.round((ib.data.value - min_volume) * 100.0 / limited_range) device:emit_event_for_endpoint(ib, capabilities.audioVolume.volume(normalized_volume)) end @@ -68,23 +72,33 @@ end function CameraAttributeHandlers.max_volume_level_handler(driver, device, ib, response) local component = device:endpoint_to_component(ib) local max_volume = ib.data.value - local min_volume = device:get_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. component) - if max_volume > camera_fields.ABS_VOL_MAX or (min_volume and max_volume <= min_volume) then + local min_volume = camera_utils.get_field_for_component(device, camera_fields.MIN_VOLUME_LEVEL, component) + -- min == max is a valid (non-adjustable) range per the Matter spec, so only max < min is malformed; + -- an out-of-bounds value is clamped, but a malformed relationship is only logged and left as-is, + -- since volume_level_handler already guards against a non-positive range. + if max_volume > camera_fields.ABS_VOL_MAX then device.log.warn(string.format("Device reported invalid maximum (%d) %s volume level range value", ib.data.value, component)) max_volume = camera_fields.ABS_VOL_MAX + elseif min_volume and max_volume < min_volume then + device.log.warn(string.format("Device reported invalid maximum (%d) %s volume level range value", ib.data.value, component)) end - device:set_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. component, max_volume) + camera_utils.set_field_for_component(device, camera_fields.MAX_VOLUME_LEVEL, component, max_volume) + camera_cfg.reconcile_profile_and_capabilities(device) end function CameraAttributeHandlers.min_volume_level_handler(driver, device, ib, response) local component = device:endpoint_to_component(ib) local min_volume = ib.data.value - local max_volume = device:get_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. component) - if min_volume < camera_fields.ABS_VOL_MIN or (max_volume and min_volume >= max_volume) then + local max_volume = camera_utils.get_field_for_component(device, camera_fields.MAX_VOLUME_LEVEL, component) + -- See max_volume_level_handler: min == max is valid, only min > max is malformed and only logged. + if min_volume < camera_fields.ABS_VOL_MIN then device.log.warn(string.format("Device reported invalid minimum (%d) %s volume level range value", ib.data.value, component)) min_volume = camera_fields.ABS_VOL_MIN + elseif max_volume and min_volume > max_volume then + device.log.warn(string.format("Device reported invalid minimum (%d) %s volume level range value", ib.data.value, component)) end - device:set_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. component, min_volume) + camera_utils.set_field_for_component(device, camera_fields.MIN_VOLUME_LEVEL, component, min_volume) + camera_cfg.reconcile_profile_and_capabilities(device) end function CameraAttributeHandlers.status_light_enabled_handler(driver, device, ib, response) diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/capability_handlers.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/capability_handlers.lua index ef52efcdbe..ebf0411f99 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/capability_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/capability_handlers.lua @@ -59,8 +59,8 @@ end function CameraCapabilityHandlers.handle_set_volume(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) - local max_volume = device:get_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. cmd.component) or camera_fields.ABS_VOL_MAX - local min_volume = device:get_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. cmd.component) or camera_fields.ABS_VOL_MIN + local max_volume = camera_utils.get_field_for_component(device, camera_fields.MAX_VOLUME_LEVEL, cmd.component) or camera_fields.ABS_VOL_MAX + local min_volume = camera_utils.get_field_for_component(device, camera_fields.MIN_VOLUME_LEVEL, cmd.component) or camera_fields.ABS_VOL_MIN -- Convert from [0, 100] to [min_volume, max_volume] before writing attribute local volume_range = max_volume - min_volume local volume = utils.round(cmd.args.volume * volume_range / 100.0 + min_volume) @@ -75,8 +75,8 @@ end function CameraCapabilityHandlers.handle_volume_up(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) - local max_volume = device:get_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. cmd.component) or camera_fields.ABS_VOL_MAX - local min_volume = device:get_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. cmd.component) or camera_fields.ABS_VOL_MIN + local max_volume = camera_utils.get_field_for_component(device, camera_fields.MAX_VOLUME_LEVEL, cmd.component) or camera_fields.ABS_VOL_MAX + local min_volume = camera_utils.get_field_for_component(device, camera_fields.MIN_VOLUME_LEVEL, cmd.component) or camera_fields.ABS_VOL_MIN local volume = device:get_latest_state(cmd.component, capabilities.audioVolume.ID, capabilities.audioVolume.volume.NAME) if not volume or volume >= max_volume then return end -- Convert from [0, 100] to [min_volume, max_volume] before writing attribute @@ -91,8 +91,8 @@ end function CameraCapabilityHandlers.handle_volume_down(driver, device, cmd) local endpoint_id = device:component_to_endpoint(cmd.component) - local max_volume = device:get_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. cmd.component) or camera_fields.ABS_VOL_MAX - local min_volume = device:get_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. cmd.component) or camera_fields.ABS_VOL_MIN + local max_volume = camera_utils.get_field_for_component(device, camera_fields.MAX_VOLUME_LEVEL, cmd.component) or camera_fields.ABS_VOL_MAX + local min_volume = camera_utils.get_field_for_component(device, camera_fields.MIN_VOLUME_LEVEL, cmd.component) or camera_fields.ABS_VOL_MIN local volume = device:get_latest_state(cmd.component, capabilities.audioVolume.ID, capabilities.audioVolume.volume.NAME) if not volume or volume <= min_volume then return end -- Convert from [0, 100] to [min_volume, max_volume] before writing attribute 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..8a5db7c5b5 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 @@ -41,6 +41,19 @@ local function set_hard_privacy_mode_presence(device, hard_privacy_mode_present) device:set_field(camera_fields.HARD_PRIVACY_MODE_PRESENT, hard_privacy_mode_present == true, { persist = true }) end +--- Leaves audioVolume as-is if the min/max levels aren't both known yet instead of guessing; +--- otherwise requires max > min to include it. +--- @param component string a profile component name +--- @return should_include boolean whether audioVolume belongs on the component argument. +local function should_include_volume_capability(device, component) + local min_volume = camera_utils.get_field_for_component(device, camera_fields.MIN_VOLUME_LEVEL, component) + local max_volume = camera_utils.get_field_for_component(device, camera_fields.MAX_VOLUME_LEVEL, component) + if min_volume == nil or max_volume == nil then + return device:supports_capability(capabilities.audioVolume, component) + end + return max_volume > min_volume +end + local function build_webrtc_supported_features() return { bundle = true, @@ -258,7 +271,9 @@ function CameraDeviceConfiguration.match_profile(device) table.insert(main_component_capabilities, capabilities.audioRecording.ID) end table.insert(microphone_component_capabilities, capabilities.audioMute.ID) - table.insert(microphone_component_capabilities, capabilities.audioVolume.ID) + if should_include_volume_capability(device, camera_fields.profile_components.microphone) then + table.insert(microphone_component_capabilities, capabilities.audioVolume.ID) + end end if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.SNAPSHOT) then table.insert(main_component_capabilities, capabilities.imageCapture.ID) @@ -268,7 +283,9 @@ function CameraDeviceConfiguration.match_profile(device) end if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.SPEAKER) then table.insert(speaker_component_capabilities, capabilities.audioMute.ID) - table.insert(speaker_component_capabilities, capabilities.audioVolume.ID) + if should_include_volume_capability(device, camera_fields.profile_components.speaker) then + table.insert(speaker_component_capabilities, capabilities.audioVolume.ID) + end end if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.IMAGE_CONTROL) then table.insert(main_component_capabilities, capabilities.imageControl.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 78792b94b3..5cc7dee498 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 @@ -82,6 +82,14 @@ function CameraUtils.feature_supported(device, cluster_id, feature_flag) return #device:get_endpoints(cluster_id, { feature_bitmap = feature_flag }) > 0 end +function CameraUtils.get_field_for_component(device, field, component) + return device:get_field(string.format("%s_%s", field, component)) +end + +function CameraUtils.set_field_for_component(device, field, component, value, additional_params) + device:set_field(string.format("%s_%s", field, component), value, additional_params) +end + function CameraUtils.update_supported_attributes(device, ib, capability, attribute) local attribute_set = device:get_latest_state( camera_fields.profile_components.main, capability.ID, capability.supportedAttributes.NAME @@ -212,18 +220,22 @@ function CameraUtils.subscribe(device) clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMax, clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMin }, + -- SpeakerMaxLevel/MinLevel and MicrophoneMaxLevel/MinLevel are subscribed alongside audioMute + -- (unconditional on the AUDIO/SPEAKER feature) rather than audioVolume, since audioVolume's own + -- presence in the profile depends on first discovering these values -- gating their subscription + -- on audioVolume already being present would make that discovery impossible. [capabilities.audioMute.ID] = { clusters.CameraAvStreamManagement.attributes.SpeakerMuted, - clusters.CameraAvStreamManagement.attributes.MicrophoneMuted - }, - [capabilities.audioVolume.ID] = { - clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel, + clusters.CameraAvStreamManagement.attributes.MicrophoneMuted, clusters.CameraAvStreamManagement.attributes.SpeakerMaxLevel, clusters.CameraAvStreamManagement.attributes.SpeakerMinLevel, - clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel, clusters.CameraAvStreamManagement.attributes.MicrophoneMaxLevel, clusters.CameraAvStreamManagement.attributes.MicrophoneMinLevel }, + [capabilities.audioVolume.ID] = { + clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel, + clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel + }, [capabilities.mode.ID] = { clusters.CameraAvStreamManagement.attributes.StatusLightBrightness }, 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 e1467edecf..6f5b1251eb 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua @@ -420,6 +420,15 @@ local additional_subscribed_attributes = { clusters.Switch.server.events.MultiPressComplete } +-- audioVolume is only added to the speaker/microphone components once a usable (max > min) volume +-- range has been reported; seed a usable range directly so unrelated tests don't have to simulate it. +local function seed_usable_volume_range(device) + device:set_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. camera_fields.profile_components.speaker, 0) + device:set_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. camera_fields.profile_components.speaker, 200) + device:set_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. camera_fields.profile_components.microphone, 0) + device:set_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. camera_fields.profile_components.microphone, 200) +end + local expected_metadata = { optional_component_capabilities = { { @@ -473,6 +482,7 @@ local expected_metadata = { } local function update_device_profile() + seed_usable_volume_range(mock_device) test.socket.matter:__queue_receive({ mock_device.id, clusters.CameraAvStreamManagement.attributes.AttributeList:build_test_report_data(mock_device, CAMERA_EP, { @@ -712,6 +722,7 @@ end test.register_coroutine_test( "Software version change should trigger camera reprofiling when camera endpoint is present", function() + seed_usable_volume_range(mock_device) test.socket.device_lifecycle:__queue_receive( mock_device:generate_info_changed({ matter_version = { hardware = 1, software = 2 } }) ) @@ -965,6 +976,293 @@ test.register_coroutine_test( } ) +local function build_fake_device_with_volume_ranges(raw_levels) + return { + profile = { components = {} }, + endpoints = { + { + endpoint_id = CAMERA_EP, + device_types = { + {device_type_id = 0x0142, device_type_revision = 1} -- Camera + }, + clusters = { + { + cluster_id = clusters.CameraAvStreamManagement.ID, + feature_map = clusters.CameraAvStreamManagement.types.Feature.AUDIO | + clusters.CameraAvStreamManagement.types.Feature.SPEAKER, + cluster_type = "SERVER" + } + } + } + }, + get_field = function(_, field) return raw_levels[field] end, + get_endpoints = function() return {} end, + supports_capability = function() return false end, + try_update_metadata = function(self, metadata) self.updated_metadata = metadata end, + } +end + +local function component_capabilities(updated_metadata, component_name) + for _, component in ipairs(updated_metadata.optional_component_capabilities) do + if component[1] == component_name then + return component[2] + end + end + return {} +end + +local function list_contains(list, value) + for _, v in ipairs(list) do + if v == value then return true end + end + return false +end + +test.register_coroutine_test( + "audioVolume should be excluded from speaker and microphone when the volume range is unusable or unknown", + function() + local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" + + -- microphone reports an unusable range (min >= max); speaker hasn't reported a range at all yet + local fake_device = build_fake_device_with_volume_ranges({ + [camera_fields.MIN_VOLUME_LEVEL .. "_microphone"] = 100, + [camera_fields.MAX_VOLUME_LEVEL .. "_microphone"] = 100, + }) + + camera_cfg.match_profile(fake_device) + + assert(fake_device.updated_metadata ~= nil, "profile update should be requested") + local speaker_capabilities = component_capabilities(fake_device.updated_metadata, "speaker") + local microphone_capabilities = component_capabilities(fake_device.updated_metadata, "microphone") + + assert(list_contains(speaker_capabilities, capabilities.audioMute.ID), "audioMute should still be present on speaker") + assert(not list_contains(speaker_capabilities, capabilities.audioVolume.ID), "audioVolume should be excluded from speaker when its range is unknown") + assert(list_contains(microphone_capabilities, capabilities.audioMute.ID), "audioMute should still be present on microphone") + assert(not list_contains(microphone_capabilities, capabilities.audioVolume.ID), "audioVolume should be excluded from microphone when min >= max") + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "audioVolume should be included on speaker and microphone when the volume range is usable", + function() + local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" + + local fake_device = build_fake_device_with_volume_ranges({ + [camera_fields.MIN_VOLUME_LEVEL .. "_speaker"] = 0, + [camera_fields.MAX_VOLUME_LEVEL .. "_speaker"] = 200, + [camera_fields.MIN_VOLUME_LEVEL .. "_microphone"] = 0, + [camera_fields.MAX_VOLUME_LEVEL .. "_microphone"] = 200, + }) + + camera_cfg.match_profile(fake_device) + + assert(fake_device.updated_metadata ~= nil, "profile update should be requested") + local speaker_capabilities = component_capabilities(fake_device.updated_metadata, "speaker") + local microphone_capabilities = component_capabilities(fake_device.updated_metadata, "microphone") + + assert(list_contains(speaker_capabilities, capabilities.audioVolume.ID), "audioVolume should be included on speaker when max > min") + assert(list_contains(microphone_capabilities, capabilities.audioVolume.ID), "audioVolume should be included on microphone when max > min") + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "audioVolume should be preserved on a component whose volume range isn't fully known yet", + function() + local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" + + -- Speaker already has audioVolume from a prior match; its raw min/max aren't in raw_levels (nil), + -- so it should be carried over as-is rather than being dropped for lack of information. + local existing_components = { + speaker = { capabilities = { audioVolume = { id = capabilities.audioVolume.ID } } } + } + local raw_levels = { + [camera_fields.MIN_VOLUME_LEVEL .. "_microphone"] = 100, + [camera_fields.MAX_VOLUME_LEVEL .. "_microphone"] = 100, -- unusable; forces a real profile change so we can inspect it + } + local fake_device = { + profile = { components = existing_components }, + endpoints = { + { + endpoint_id = CAMERA_EP, + device_types = { + {device_type_id = 0x0142, device_type_revision = 1} -- Camera + }, + clusters = { + { + cluster_id = clusters.CameraAvStreamManagement.ID, + feature_map = clusters.CameraAvStreamManagement.types.Feature.AUDIO | + clusters.CameraAvStreamManagement.types.Feature.SPEAKER, + cluster_type = "SERVER" + } + } + } + }, + get_field = function(_, field) return raw_levels[field] end, + get_endpoints = function() return {} end, + supports_capability = function(_, capability, component) + local comp = existing_components[component] + if not comp then return false end + for _, cap in pairs(comp.capabilities) do + if cap.id == capability.ID then return true end + end + return false + end, + try_update_metadata = function(self, metadata) self.updated_metadata = metadata end, + } + + camera_cfg.match_profile(fake_device) + + assert(fake_device.updated_metadata ~= nil, "profile update should be requested") + local speaker_capabilities = component_capabilities(fake_device.updated_metadata, "speaker") + local microphone_capabilities = component_capabilities(fake_device.updated_metadata, "microphone") + + assert(list_contains(speaker_capabilities, capabilities.audioVolume.ID), "audioVolume should be preserved on speaker while its range is unknown") + assert(list_contains(microphone_capabilities, capabilities.audioMute.ID), "audioMute should still be present on microphone") + assert(not list_contains(microphone_capabilities, capabilities.audioVolume.ID), "audioVolume should be excluded from microphone when min >= max") + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Speaker audioVolume should be removed from the profile when its volume range becomes unusable", + function() + update_device_profile() + test.wait_for_events() + + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.server.attributes.SpeakerMaxLevel:build_test_report_data(mock_device, CAMERA_EP, 0) + }) + + local updated_expected_metadata = { + optional_component_capabilities = { + { "main", + { "videoCapture2", "cameraViewportSettings", "videoStreamSettings", "localMediaStorage", "audioRecording", + "cameraPrivacyMode", "imageControl", "hdr", "nightVision", "mechanicalPanTiltZoom", "zoneManagement", + "webrtc", "motionSensor", "sounds" } + }, + { "statusLed", + { "switch", "mode" } + }, + { "speaker", + { "audioMute" } -- audioVolume removed: reported max (0) is no longer greater than the known min (0) + }, + { "microphone", + { "audioMute", "audioVolume" } + }, + { "doorbell", + { "button" } + } + }, + profile = "camera" + } + mock_device:expect_metadata_update(updated_expected_metadata) + test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, DOORBELL_EP)}) + end, + { + min_api_version = 14 + } +) + +test.register_coroutine_test( + "Speaker/Microphone volume range attributes should stay subscribed even when audioVolume isn't granted yet", + function() + -- Fresh pairing: no volume range has been reported yet, so audioVolume shouldn't be granted on + -- the very first profile match. + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AttributeList:build_test_report_data(mock_device, CAMERA_EP, { + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID), + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID) + }) + }) + local first_pass_metadata = { + optional_component_capabilities = { + { "main", + { "videoCapture2", "cameraViewportSettings", "videoStreamSettings", "localMediaStorage", "audioRecording", + "cameraPrivacyMode", "imageControl", "hdr", "nightVision", "mechanicalPanTiltZoom", "zoneManagement", + "webrtc", "motionSensor", "sounds" } + }, + { "statusLed", { "switch", "mode" } }, + { "speaker", { "audioMute" } }, + { "microphone", { "audioMute" } }, + { "doorbell", { "button" } } + }, + profile = "camera" + } + mock_device:expect_metadata_update(first_pass_metadata) + test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, DOORBELL_EP)}) + test.wait_for_events() + + local first_pass_profile = t_utils.get_profile_definition( + "camera.yml", {enabled_optional_capabilities = first_pass_metadata.optional_component_capabilities} + ) + test.wait_for_events() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = first_pass_profile })) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.webrtc.supportedFeatures( + {audio="sendrecv", bundle=true, order="audio/video", supportTrickleICE=true, turnSource="player", video="recvonly"} + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.supportedAttributes( + {"pan", "panRange", "tilt", "tiltRange", "zoom", "zoomRange", "presets", "maxPresets"} + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.zoneManagement.supportedFeatures( + {"triggerAugmentation", "perZoneSensitivity"} + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.localMediaStorage.supportedAttributes( + {"localVideoRecording"} + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.audioRecording.audioRecording("enabled")) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.videoStreamSettings.supportedFeatures( + {"liveStreaming", "clipRecording", "perStreamViewports", "watermark", "onScreenDisplay"} + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.cameraPrivacyMode.supportedAttributes( + {"softRecordingPrivacyMode", "softLivestreamPrivacyMode"} + )) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.cameraPrivacyMode.supportedCommands( + {"setSoftRecordingPrivacyMode", "setSoftLivestreamPrivacyMode"} + )) + ) + + -- audioVolume isn't active yet, so its current-value attributes shouldn't be (re-)subscribed, but + -- the min/max discovery attributes -- now bundled with audioMute -- must still be. Without that, + -- the driver could never learn the range needed to decide whether audioVolume belongs at all. + for _, attr in ipairs(additional_subscribed_attributes) do + if attr ~= clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel and + attr ~= clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel then + subscribe_request:merge(attr:subscribe(mock_device)) + end + end + test.socket.matter:__expect_send({mock_device.id, subscribe_request}) + test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, DOORBELL_EP)}) + end, + { + min_api_version = 14 + } +) + test.register_coroutine_test( "Camera privacy mode supportedAttributes/supportedCommands should only expose hardPrivacyMode when PRIV feature is absent", function() @@ -1282,6 +1580,56 @@ test.register_coroutine_test( } ) +test.register_coroutine_test( + "A volume level report racing a zero-width range update should not divide by zero", + function() + update_device_profile() + test.wait_for_events() + + -- Narrow the range to min == max. This drops audioVolume from the profile, but the internal + -- MAX/MIN_VOLUME_LEVEL fields used for normalization are updated synchronously, before the + -- platform round-trips the narrowed profile back down. + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.server.attributes.SpeakerMaxLevel:build_test_report_data(mock_device, CAMERA_EP, 100) + }) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.server.attributes.SpeakerMinLevel:build_test_report_data(mock_device, CAMERA_EP, 100) + }) + local updated_expected_metadata = { + optional_component_capabilities = { + { "main", + { "videoCapture2", "cameraViewportSettings", "videoStreamSettings", "localMediaStorage", "audioRecording", + "cameraPrivacyMode", "imageControl", "hdr", "nightVision", "mechanicalPanTiltZoom", "zoneManagement", + "webrtc", "motionSensor", "sounds" } + }, + { "statusLed", { "switch", "mode" } }, + { "speaker", { "audioMute" } }, -- audioVolume dropped: the range is now zero-width (100 == 100) + { "microphone", { "audioMute", "audioVolume" } }, + { "doorbell", { "button" } } + }, + profile = "camera" + } + mock_device:expect_metadata_update(updated_expected_metadata) + test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, DOORBELL_EP)}) + test.wait_for_events() + + -- A late SpeakerVolumeLevel report can still arrive before the narrowed profile comes back down. + -- It should report a fixed value instead of dividing by (max - min) == 0. + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel:build_test_report_data(mock_device, CAMERA_EP, 100) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("speaker", capabilities.audioVolume.volume(0)) + ) + end, + { + min_api_version = 14 + } +) + test.register_coroutine_test( "Status Light Enabled reports should generate appropriate events", function()