Summary
There are two related correctness issues in the camera speaker/microphone path:
- the speaker component mapping should be based on the AVSM
SPEAKER feature, not an unrelated feature;
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
- Use a Matter camera whose volume range is not numerically
0..100.
- Set the SmartThings audioVolume state to
100%.
- 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
- Receive
SpeakerVolumeLevel before SpeakerMinLevel / SpeakerMaxLevel.
- The first emitted SmartThings percentage is calculated using
0..254.
- Receive the actual min/max.
- 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
- Build the speaker component from
Feature.SPEAKER.
- Add a field for the latest raw volume per component.
- Refactor raw-to-normalized emission into one helper.
- Re-emit normalized volume when min/max reports arrive.
- Change
volumeUp/volumeDown boundary tests to 100/0.
- 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:
- raw min maps to
0%;
- raw max maps to
100%;
volumeUp at 100% sends nothing;
volumeDown at 0% sends nothing;
- a raw volume received before min/max is recalculated after the real range arrives;
- 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,
Summary
There are two related correctness issues in the camera speaker/microphone path:
SPEAKERfeature, not an unrelated feature;volumeUp/volumeDowncompare 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/MicrophoneVolumeLevelarrives 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/SmartThingsEdgeDriversmainat commit60bbf2716412fdf04fd59f967bdd37be6995d664(2026-08-12).Real-device validation used:
0x115F / 0x30134.5.70/40050701.5.10x0142The 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 SmartThings0..100%using the currently cached min/max values:camera_handlers/attribute_handlers.lualines 57-90If the range has not arrived yet it uses the fallback
0..254.When
SpeakerMaxLevel,SpeakerMinLevel,MicrophoneMaxLevelorMicrophoneMinLevellater 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:camera_handlers/capability_handlers.lualines 76-109Current logic effectively does:
For a Matter range such as
0..254:so the driver does not recognize that SmartThings is already at 100%.
Expected behavior
Boundary checks should be performed in the normalized SmartThings domain:
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:
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:
for the
speakercomponent, 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
0..100.100%.volumeUp.Current code compares
100with the raw Matter maximum, so with a raw max greater than 100 the boundary check is false.Report ordering
SpeakerVolumeLevelbeforeSpeakerMinLevel/SpeakerMaxLevel.0..254.G350 evidence
The G350 advertises:
and SmartThings was receiving real normalized values, for example:
The corrected test driver retained the raw volume, renormalized when limits arrived, and used
0/100for 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
Feature.SPEAKER.volumeUp/volumeDownboundary tests to100/0.Affected files
src/sub_drivers/camera/camera_utils/utils.luasrc/sub_drivers/camera/camera_utils/fields.luasrc/sub_drivers/camera/camera_handlers/attribute_handlers.luasrc/sub_drivers/camera/camera_handlers/capability_handlers.luasrc/test/test_matter_camera.luaSuggested regression coverage
Use a non-trivial raw range, for example
20..200.Verify:
0%;100%;volumeUpat100%sends nothing;volumeDownat0%sends nothing;Feature.SPEAKER, not VIDEO.Minimal suggested diff