Skip to content

Matter camera: speaker component mapping and audio volume handling use inconsistent feature/range semantics #3185

Description

@ldeora

Summary

There are two related correctness issues in the camera speaker/microphone path:

  1. the speaker component mapping should be based on the AVSM SPEAKER feature, not an unrelated feature;
  2. volumeUp / volumeDown compare a SmartThings normalized percentage with raw Matter min/max values.

The second issue mixes two different units and can produce incorrect boundary behavior.

There is also a report-order problem: if SpeakerVolumeLevel / MicrophoneVolumeLevel arrives before the corresponding min/max attributes, the current value is normalized using fallback limits and is not recomputed when the real limits later arrive.

Environment / verification

Verified against SmartThingsCommunity/SmartThingsEdgeDrivers main at commit
60bbf2716412fdf04fd59f967bdd37be6995d664 (2026-08-12).

Real-device validation used:

  • Aqara Camera Hub G350
  • VID/PID: 0x115F / 0x3013
  • firmware: 4.5.70 / 4005070
  • reported Matter SpecificationVersion: 1.5.1
  • Camera device type: 0x0142
  • Camera endpoint in this device: endpoint 2

The proposed changes are generic Matter-camera changes. No Aqara VID/PID fingerprint or proprietary Aqara cluster handling is required.

Current volume behavior

volume_level_handler() converts a raw Matter level to SmartThings 0..100% using the currently cached min/max values:

If the range has not arrived yet it uses the fallback 0..254.

When SpeakerMaxLevel, SpeakerMinLevel, MicrophoneMaxLevel or MicrophoneMinLevel later arrives, the current code only stores the new bound; it does not renormalize the most recent raw level.

handle_volume_up() / handle_volume_down() then read the SmartThings percentage but compare it with raw Matter bounds:

Current logic effectively does:

-- volume is 0..100 SmartThings %
if volume >= max_volume then return end  -- max_volume is raw Matter level
...
if volume <= min_volume then return end  -- min_volume is raw Matter level

For a Matter range such as 0..254:

SmartThings volume = 100
Matter max         = 254

100 >= 254 -> false

so the driver does not recognize that SmartThings is already at 100%.

Expected behavior

Boundary checks should be performed in the normalized SmartThings domain:

if volume >= 100 then return end
if volume <= 0 then return end

Then convert the requested normalized value to the Matter range for the write.

The driver should also retain the latest raw Matter volume. When min/max limits arrive or change, it should renormalize that raw value and emit the corrected SmartThings percentage.

Suggested pattern:

volume report:
  store raw volume
  emit normalized(raw, current min/max)

min/max report:
  store new bound
  if raw volume exists:
    emit normalized(raw, updated min/max)

Guard against an invalid/zero-width range.

Speaker component mapping

The camera component map should be built from the AVSM feature that actually owns the speaker attributes.

The production code should use:

CameraAvStreamManagement Feature.SPEAKER

for the speaker component, independently of whether VIDEO is present.

This matters for generic cameras whose audio/speaker feature combination differs from today's test fixture.

Reproduction

Volume boundary

  1. Use a Matter camera whose volume range is not numerically 0..100.
  2. Set the SmartThings audioVolume state to 100%.
  3. Execute volumeUp.

Current code compares 100 with the raw Matter maximum, so with a raw max greater than 100 the boundary check is false.

Report ordering

  1. Receive SpeakerVolumeLevel before SpeakerMinLevel / SpeakerMaxLevel.
  2. The first emitted SmartThings percentage is calculated using 0..254.
  3. Receive the actual min/max.
  4. Current code does not recompute the already-emitted level.

G350 evidence

The G350 advertises:

SpeakerMuted
SpeakerVolumeLevel
SpeakerMaxLevel
SpeakerMinLevel

MicrophoneMuted
MicrophoneVolumeLevel
MicrophoneMaxLevel
MicrophoneMinLevel

and SmartThings was receiving real normalized values, for example:

speaker volume    21%
microphone volume 39%

The corrected test driver retained the raw volume, renormalized when limits arrived, and used 0/100 for normalized boundary checks. No regression was observed in the camera's speaker/microphone state handling.

A dedicated volume-up/down boundary test is still recommended because the original defect is primarily exposed by non-100 raw ranges.

Suggested code changes

  1. Build the speaker component from Feature.SPEAKER.
  2. Add a field for the latest raw volume per component.
  3. Refactor raw-to-normalized emission into one helper.
  4. Re-emit normalized volume when min/max reports arrive.
  5. Change volumeUp/volumeDown boundary tests to 100/0.
  6. Continue converting the resulting percentage into the device's Matter range for the write.

Affected files

  • src/sub_drivers/camera/camera_utils/utils.lua
  • src/sub_drivers/camera/camera_utils/fields.lua
  • src/sub_drivers/camera/camera_handlers/attribute_handlers.lua
  • src/sub_drivers/camera/camera_handlers/capability_handlers.lua
  • src/test/test_matter_camera.lua

Suggested regression coverage

Use a non-trivial raw range, for example 20..200.

Verify:

  1. raw min maps to 0%;
  2. raw max maps to 100%;
  3. volumeUp at 100% sends nothing;
  4. volumeDown at 0% sends nothing;
  5. a raw volume received before min/max is recalculated after the real range arrives;
  6. speaker component creation depends on Feature.SPEAKER, not VIDEO.

Minimal suggested diff

diff --git a/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua b/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua
index 53b3571..af8702a 100644
--- a/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua
+++ b/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua
@@ -57,16 +57,24 @@ function CameraAttributeHandlers.muted_handler(driver, device, ib, response)
   device:emit_event_for_endpoint(ib, capabilities.audioMute.mute(ib.data.value and "muted" or "unmuted"))
 end
 
-function CameraAttributeHandlers.volume_level_handler(driver, device, ib, response)
-  local component = device:endpoint_to_component(ib)
+local function emit_normalized_volume(device, ib, component, raw_volume)
   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 limited_range = max_volume - min_volume
-  local normalized_volume = utils.round((ib.data.value - min_volume) * 100.0 / limited_range)
+  if limited_range <= 0 then
+    device.log.warn(string.format("Invalid %s volume range: %s..%s", component, tostring(min_volume), tostring(max_volume)))
+    return
+  end
+  local normalized_volume = utils.clamp_value(utils.round((raw_volume - min_volume) * 100.0 / limited_range), 0, 100)
   device:emit_event_for_endpoint(ib, capabilities.audioVolume.volume(normalized_volume))
 end
 
+function CameraAttributeHandlers.volume_level_handler(driver, device, ib, response)
+  local component = device:endpoint_to_component(ib)
+  device:set_field(camera_fields.CURRENT_VOLUME_LEVEL .. "_" .. component, ib.data.value)
+  emit_normalized_volume(device, ib, component, ib.data.value)
+end
+
 function CameraAttributeHandlers.max_volume_level_handler(driver, device, ib, response)
   local component = device:endpoint_to_component(ib)
   local max_volume = ib.data.value
@@ -76,6 +84,8 @@ function CameraAttributeHandlers.max_volume_level_handler(driver, device, ib, re
     max_volume = camera_fields.ABS_VOL_MAX
   end
   device:set_field(camera_fields.MAX_VOLUME_LEVEL .. "_" .. component, max_volume)
+  local raw_volume = device:get_field(camera_fields.CURRENT_VOLUME_LEVEL .. "_" .. component)
+  if raw_volume ~= nil then emit_normalized_volume(device, ib, component, raw_volume) end
 end
 
 function CameraAttributeHandlers.min_volume_level_handler(driver, device, ib, response)
@@ -87,6 +97,8 @@ function CameraAttributeHandlers.min_volume_level_handler(driver, device, ib, re
     min_volume = camera_fields.ABS_VOL_MIN
   end
   device:set_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. component, min_volume)
+  local raw_volume = device:get_field(camera_fields.CURRENT_VOLUME_LEVEL .. "_" .. component)
+  if raw_volume ~= nil then emit_normalized_volume(device, ib, component, raw_volume) end
 end
 
 function CameraAttributeHandlers.status_light_enabled_handler(driver, device, ib, response)
diff --git a/src/sub_drivers/camera/camera_handlers/capability_handlers.lua b/src/sub_drivers/camera/camera_handlers/capability_handlers.lua
index a26afd0..204c017 100644
--- a/src/sub_drivers/camera/camera_handlers/capability_handlers.lua
+++ b/src/sub_drivers/camera/camera_handlers/capability_handlers.lua
@@ -78,7 +78,7 @@ function CameraCapabilityHandlers.handle_volume_up(driver, device, cmd)
   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 volume = device:get_latest_state(cmd.component, capabilities.audioVolume.ID, capabilities.audioVolume.volume.NAME)
-  if not volume or volume >= max_volume then return end
+  if not volume or volume >= 100 then return end
   -- Convert from [0, 100] to [min_volume, max_volume] before writing attribute
   local volume_range = max_volume - min_volume
   local converted_volume = utils.round((volume + 1) * volume_range / 100.0 + min_volume)
@@ -94,7 +94,7 @@ function CameraCapabilityHandlers.handle_volume_down(driver, device, cmd)
   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 volume = device:get_latest_state(cmd.component, capabilities.audioVolume.ID, capabilities.audioVolume.volume.NAME)
-  if not volume or volume <= min_volume then return end
+  if not volume or volume <= 0 then return end
   -- Convert from [0, 100] to [min_volume, max_volume] before writing attribute
   local volume_range = max_volume - min_volume
   local converted_volume = utils.round((volume - 1) * volume_range / 100.0 + min_volume)
diff --git a/src/sub_drivers/camera/camera_utils/fields.lua b/src/sub_drivers/camera/camera_utils/fields.lua
index c88f177..e3aa2ab 100644
--- a/src/sub_drivers/camera/camera_utils/fields.lua
+++ b/src/sub_drivers/camera/camera_utils/fields.lua
@@ -16,6 +16,7 @@ CameraFields.TRIGGERED_ZONES = "__triggered_zones"
 CameraFields.DPTZ_VIEWPORTS = "__dptz_viewports"
 CameraFields.STATUS_LIGHT_ENABLED_PRESENT = "__status_light_enabled_present"
 CameraFields.STATUS_LIGHT_BRIGHTNESS_PRESENT = "__status_light_brightness_present"
+CameraFields.CURRENT_VOLUME_LEVEL = "__current_volume_level"
 
 CameraFields.CameraAVSMFeatureMapAttr = { ID = 0xFFFC, cluster = clusters.CameraAvStreamManagement.ID }
 CameraFields.CameraAVSULMFeatureMapAttr = { ID = 0xFFFC, cluster = clusters.CameraAvSettingsUserLevelManagement.ID }
diff --git a/src/sub_drivers/camera/camera_utils/utils.lua b/src/sub_drivers/camera/camera_utils/utils.lua
index 78792b9..a2a4e47 100644
--- a/src/sub_drivers/camera/camera_utils/utils.lua
+++ b/src/sub_drivers/camera/camera_utils/utils.lua
@@ -37,7 +37,7 @@ function CameraUtils.update_camera_component_map(device)
         },
       }
     end
-    if CameraUtils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.VIDEO) then
+    if CameraUtils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.SPEAKER) then
       component_map.speaker = {
         endpoint_id = camera_av_ep_ids[1],
         cluster_id = clusters.CameraAvStreamManagement.ID,

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions