Skip to content

Matter camera: expose standard switch as master camera/privacy control when Matter Privacy is supported #3182

Description

@ldeora

Summary

The Matter camera subdriver already supports the standard Matter Privacy model through the SmartThings cameraPrivacyMode capability, but a Matter camera with Privacy has no normal main.switch capability.

That leaves users without the conventional camera on/off control on the dashboard and without the simplest on/off camera control in routines.

A practical mapping has now been tested successfully on a real Matter 1.5.1 camera:

switch ON  -> soft recording privacy disabled
              soft livestream privacy disabled

switch OFF -> soft recording privacy enabled
              soft livestream privacy enabled

HardPrivacyModeOn remains read-only and must never be written.

The existing granular cameraPrivacyMode capability should remain available; switch is a user-facing master abstraction, not a replacement for the detailed Matter states.

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 behavior

The current camera profile has:

  • cameraPrivacyMode on main;
  • switch only on the optional statusLed component.

See:

The profile's dashboard has no switch state/action, and its detail/routine presentation also omits main.switch:

The current camera capability handlers use switch only as the status LED control:

The three Matter privacy attributes are already handled:

SoftRecordingPrivacyModeEnabled
SoftLivestreamPrivacyModeEnabled
HardPrivacyModeOn

and the two soft attributes already have write commands through cameraPrivacyMode.

Reproduction

  1. Join a Matter camera that exposes AVSM Privacy.
  2. Allow the camera profile to initialize.
  3. Inspect its SmartThings capabilities/presentation.

On the G350 before this change, SmartThings exposed:

cameraPrivacyMode
  softRecordingPrivacyMode
  softLivestreamPrivacyMode
  hardPrivacyMode

but no main.switch.

As a result:

  • the device page had no simple camera on/off control;
  • routines could use the detailed privacy capability, but not the normal SmartThings on/off abstraction;
  • the camera dashboard had no power/privacy button.

Existing SmartThings UI precedent

A cloud-connected Aeotec Cam 360 integrated by SmartThings exposes:

main.switch

and its presentation includes:

Dashboard state:  {switch.value}
Dashboard action: standbyPowerSwitch
Detail view:      switch
Automation:       switch condition + switch action

This provides a useful existing SmartThings camera UX precedent.

Image

Expected behavior

When Matter Privacy is supported and at least one writable soft privacy attribute exists, the camera should expose an optional switch on main.

Suggested semantics:

switch ON:
  write false to each supported writable soft privacy attribute

switch OFF:
  write true to each supported writable soft privacy attribute

State derivation:

any supported privacy state active
    -> switch = off

all supported privacy states known inactive
    -> switch = on

required state not known yet
    -> do not invent a switch state

HardPrivacyModeOn should contribute to the reported state (off when active) but must remain read-only.

Suggested code changes

Profile

Add optional switch to main:

- id: switch
  version: 1
  optional: true

Expose it in the camera presentation:

deviceConfig:
  dashboard:
    states:
      - component: main
        capability: switch
        version: 1
    actions:
      - component: main
        capability: switch
        version: 1

  detailView:
    - component: main
      capability: switch
      version: 1

  automation:
    conditions:
      - component: main
        capability: switch
        version: 1
    actions:
      - component: main
        capability: switch
        version: 1

The explicit dashboard.actions entry is important: testing showed that merely adding the capability to the profile/detail/routine configuration did not create an interactive dashboard button.

Dynamic profiling

Enable main.switch only when AVSM Privacy is supported and at least one writable soft privacy attribute is available.

Commands

Dispatch switch by component so main and statusLed remain independent:

if cmd.component == "main" then
  -- main ON = privacy off
  -- main OFF = privacy on
elseif cmd.component == "statusLed" then
  -- retain existing StatusLightEnabled behavior
end

Write only:

SoftRecordingPrivacyModeEnabled
SoftLivestreamPrivacyModeEnabled

Never write HardPrivacyModeOn.

Attribute reports

Track the three privacy states and derive switch state after each report. Preserve the individual cameraPrivacyMode events unchanged.

G350 evidence

The G350 on firmware 4.5.70 advertises all three standard privacy attributes.

A test build implementing the mapping above was installed on the real camera.

Results:

  • main.switch appeared;
  • ON/OFF worked;
  • the switch was available in routines;
  • after adding the dashboard states and actions entries, the on/off button appeared on the dashboard tile;
  • the existing cameraPrivacyMode capability remained available.

This was tested successfully in the SmartThings app.

Image Image

Affected files

  • profiles/camera.yml
  • src/sub_drivers/camera/camera_utils/device_configuration.lua
  • src/sub_drivers/camera/camera_utils/utils.lua
  • src/sub_drivers/camera/camera_handlers/attribute_handlers.lua
  • src/sub_drivers/camera/camera_handlers/capability_handlers.lua
  • src/sub_drivers/camera/init.lua
  • src/test/test_matter_camera.lua

Suggested regression coverage

Verify:

  1. Privacy feature + writable soft attributes -> main.switch is enabled.
  2. switch OFF writes true to both supported soft privacy attributes.
  3. switch ON writes false to both supported soft privacy attributes.
  4. HardPrivacyModeOn is never written.
  5. hard privacy active -> switch reports off.
  6. mixed soft privacy state -> switch reports off.
  7. all supported privacy states inactive -> switch reports on.
  8. statusLed.switch continues to control only StatusLightEnabled.
  9. profile includes switch in dashboard state/action, detail view and automation.

Minimal suggested diff

diff --git a/profiles/camera.yml b/profiles/camera.yml
index 1f911a9..dd277cd 100644
--- a/profiles/camera.yml
+++ b/profiles/camera.yml
@@ -35,6 +35,9 @@ components:
       - id: cameraPrivacyMode
         version: 1
         optional: true
+      - id: switch
+        version: 1
+        optional: true
       - id: zoneManagement
         version: 1
         optional: true
@@ -92,6 +95,9 @@ components:
 deviceConfig:
   dashboard:
     states:
+    - component: main
+      capability: switch
+      version: 1
     - component: main
       capability: imageCapture
       version: 1
@@ -106,6 +112,10 @@ deviceConfig:
         operator: CONTAINS
         operand: T
         isOffline: false
+    actions:
+    - component: main
+      capability: switch
+      version: 1
     basicPlus:
       - displayType: camera
         camera:
@@ -134,6 +144,9 @@ deviceConfig:
     - component: main
       capability: motionSensor
       version: 1
+    - component: main
+      capability: switch
+      version: 1
     - component: main
       capability: videoCapture2
       version: 1
@@ -142,7 +155,13 @@ deviceConfig:
     - component: main
       capability: motionSensor
       version: 1
+    - component: main
+      capability: switch
+      version: 1
     actions:
+    - component: main
+      capability: switch
+      version: 1
     - component: main
       capability: videoCapture2
       version: 1
diff --git a/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua b/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua
index 53b3571..e02d9c5 100644
--- a/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua
+++ b/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua
@@ -24,6 +24,36 @@ CameraAttributeHandlers.enabled_state_factory = function(attribute)
   end
 end
 
+local function main_component_has_switch(device)
+  local main = device.profile and device.profile.components and device.profile.components[camera_fields.profile_components.main]
+  for _, capability in pairs((main and main.capabilities) or {}) do
+    if capability.id == capabilities.switch.ID then return true end
+  end
+  return false
+end
+
+local function emit_privacy_switch_state(device, ib)
+  if not main_component_has_switch(device) then return end
+  local switch_state = camera_utils.get_privacy_switch_state(device)
+  if switch_state == "off" then
+    device:emit_event_for_endpoint(ib, capabilities.switch.switch.off())
+  elseif switch_state == "on" then
+    device:emit_event_for_endpoint(ib, capabilities.switch.switch.on())
+  end
+end
+
+CameraAttributeHandlers.privacy_state_factory = function(attribute, matter_attribute)
+  return function(driver, device, ib, response)
+    local enabled = ib.data.value == true
+    device:emit_event_for_endpoint(ib, attribute(enabled and "enabled" or "disabled"))
+    camera_utils.update_privacy_state(device, matter_attribute.ID, enabled)
+    if attribute == capabilities.cameraPrivacyMode.hardPrivacyMode then
+      camera_utils.update_supported_attributes(device, ib, capabilities.cameraPrivacyMode, "hardPrivacyMode")
+    end
+    emit_privacy_switch_state(device, ib)
+  end
+end
+
 CameraAttributeHandlers.night_vision_factory = function(attribute)
   return function(driver, device, ib, response)
     if camera_fields.tri_state_map[ib.data.value] then
diff --git a/src/sub_drivers/camera/camera_handlers/capability_handlers.lua b/src/sub_drivers/camera/camera_handlers/capability_handlers.lua
index a26afd0..d7116a8 100644
--- a/src/sub_drivers/camera/camera_handlers/capability_handlers.lua
+++ b/src/sub_drivers/camera/camera_handlers/capability_handlers.lua
@@ -120,14 +120,37 @@ function CameraCapabilityHandlers.handle_set_status_light_mode(driver, device, c
     clusters.Global.types.ThreeLevelAutoEnum[level_auto_value]))
 end
 
-function CameraCapabilityHandlers.handle_status_led_on(driver, device, cmd)
-  local endpoint_id = device:component_to_endpoint(cmd.component)
-  device:send(clusters.CameraAvStreamManagement.attributes.StatusLightEnabled:write(device, endpoint_id, true))
+local function set_camera_privacy_from_switch(device, privacy_enabled)
+  local endpoint_id = device:component_to_endpoint(camera_fields.profile_components.main)
+  device:send(clusters.CameraAvStreamManagement.attributes.SoftRecordingPrivacyModeEnabled:write(
+    device, endpoint_id, privacy_enabled
+  ))
+  device:send(clusters.CameraAvStreamManagement.attributes.SoftLivestreamPrivacyModeEnabled:write(
+    device, endpoint_id, privacy_enabled
+  ))
 end
 
-function CameraCapabilityHandlers.handle_status_led_off(driver, device, cmd)
-  local endpoint_id = device:component_to_endpoint(cmd.component)
-  device:send(clusters.CameraAvStreamManagement.attributes.StatusLightEnabled:write(device, endpoint_id, false))
+local function set_status_led(device, enabled)
+  local endpoint_id = device:component_to_endpoint(camera_fields.profile_components.statusLed)
+  device:send(clusters.CameraAvStreamManagement.attributes.StatusLightEnabled:write(device, endpoint_id, enabled))
+end
+
+function CameraCapabilityHandlers.handle_switch_on(driver, device, cmd)
+  if cmd.component == camera_fields.profile_components.main then
+    -- Camera ON means privacy OFF. HardPrivacyModeOn remains read-only.
+    set_camera_privacy_from_switch(device, false)
+  elseif cmd.component == camera_fields.profile_components.statusLed then
+    set_status_led(device, true)
+  end
+end
+
+function CameraCapabilityHandlers.handle_switch_off(driver, device, cmd)
+  if cmd.component == camera_fields.profile_components.main then
+    -- Camera OFF means enable both writable soft privacy modes.
+    set_camera_privacy_from_switch(device, true)
+  elseif cmd.component == camera_fields.profile_components.statusLed then
+    set_status_led(device, false)
+  end
 end
 
 function CameraCapabilityHandlers.handle_audio_recording(driver, device, cmd)
diff --git a/src/sub_drivers/camera/camera_utils/device_configuration.lua b/src/sub_drivers/camera/camera_utils/device_configuration.lua
index 0619194..45c4e88 100644
--- a/src/sub_drivers/camera/camera_utils/device_configuration.lua
+++ b/src/sub_drivers/camera/camera_utils/device_configuration.lua
@@ -236,6 +236,7 @@ function CameraDeviceConfiguration.match_profile(device)
         end
         if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.PRIVACY) then
           table.insert(main_component_capabilities, capabilities.cameraPrivacyMode.ID)
+          table.insert(main_component_capabilities, capabilities.switch.ID)
         end
         if clus_has_feature(clusters.CameraAvStreamManagement.types.Feature.SPEAKER) then
           table.insert(speaker_component_capabilities, capabilities.audioMute.ID)
diff --git a/src/sub_drivers/camera/camera_utils/fields.lua b/src/sub_drivers/camera/camera_utils/fields.lua
index c88f177..69881fa 100644
--- a/src/sub_drivers/camera/camera_utils/fields.lua
+++ b/src/sub_drivers/camera/camera_utils/fields.lua
@@ -16,6 +16,9 @@ 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.SOFT_RECORDING_PRIVACY = "__soft_recording_privacy"
+CameraFields.SOFT_LIVESTREAM_PRIVACY = "__soft_livestream_privacy"
+CameraFields.HARD_PRIVACY = "__hard_privacy"
 
 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..5d0ffda 100644
--- a/src/sub_drivers/camera/camera_utils/utils.lua
+++ b/src/sub_drivers/camera/camera_utils/utils.lua
@@ -82,6 +82,33 @@ function CameraUtils.feature_supported(device, cluster_id, feature_flag)
   return #device:get_endpoints(cluster_id, { feature_bitmap = feature_flag }) > 0
 end
 
+local privacy_state_fields = {
+  [clusters.CameraAvStreamManagement.attributes.SoftRecordingPrivacyModeEnabled.ID] = camera_fields.SOFT_RECORDING_PRIVACY,
+  [clusters.CameraAvStreamManagement.attributes.SoftLivestreamPrivacyModeEnabled.ID] = camera_fields.SOFT_LIVESTREAM_PRIVACY,
+  [clusters.CameraAvStreamManagement.attributes.HardPrivacyModeOn.ID] = camera_fields.HARD_PRIVACY,
+}
+
+function CameraUtils.update_privacy_state(device, attribute_id, value)
+  local field = privacy_state_fields[attribute_id]
+  if field ~= nil then
+    device:set_field(field, value == true)
+  end
+end
+
+function CameraUtils.get_privacy_switch_state(device)
+  local soft_recording = device:get_field(camera_fields.SOFT_RECORDING_PRIVACY)
+  local soft_livestream = device:get_field(camera_fields.SOFT_LIVESTREAM_PRIVACY)
+  local hard_privacy = device:get_field(camera_fields.HARD_PRIVACY)
+
+  if hard_privacy == true or soft_recording == true or soft_livestream == true then
+    return "off"
+  end
+  if soft_recording == false and soft_livestream == false then
+    return "on"
+  end
+  return nil
+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
diff --git a/src/sub_drivers/camera/init.lua b/src/sub_drivers/camera/init.lua
index 2aa698a..b3509e1 100644
--- a/src/sub_drivers/camera/init.lua
+++ b/src/sub_drivers/camera/init.lua
@@ -78,9 +78,9 @@ local camera_handler = {
         [clusters.CameraAvStreamManagement.attributes.ImageFlipHorizontal.ID] = attribute_handlers.enabled_state_factory(capabilities.imageControl.imageFlipHorizontal),
         [clusters.CameraAvStreamManagement.attributes.ImageFlipVertical.ID] = attribute_handlers.enabled_state_factory(capabilities.imageControl.imageFlipVertical),
         [clusters.CameraAvStreamManagement.attributes.ImageRotation.ID] = attribute_handlers.image_rotation_handler,
-        [clusters.CameraAvStreamManagement.attributes.SoftRecordingPrivacyModeEnabled.ID] = attribute_handlers.enabled_state_factory(capabilities.cameraPrivacyMode.softRecordingPrivacyMode),
-        [clusters.CameraAvStreamManagement.attributes.SoftLivestreamPrivacyModeEnabled.ID] = attribute_handlers.enabled_state_factory(capabilities.cameraPrivacyMode.softLivestreamPrivacyMode),
-        [clusters.CameraAvStreamManagement.attributes.HardPrivacyModeOn.ID] = attribute_handlers.enabled_state_factory(capabilities.cameraPrivacyMode.hardPrivacyMode),
+        [clusters.CameraAvStreamManagement.attributes.SoftRecordingPrivacyModeEnabled.ID] = attribute_handlers.privacy_state_factory(capabilities.cameraPrivacyMode.softRecordingPrivacyMode, clusters.CameraAvStreamManagement.attributes.SoftRecordingPrivacyModeEnabled),
+        [clusters.CameraAvStreamManagement.attributes.SoftLivestreamPrivacyModeEnabled.ID] = attribute_handlers.privacy_state_factory(capabilities.cameraPrivacyMode.softLivestreamPrivacyMode, clusters.CameraAvStreamManagement.attributes.SoftLivestreamPrivacyModeEnabled),
+        [clusters.CameraAvStreamManagement.attributes.HardPrivacyModeOn.ID] = attribute_handlers.privacy_state_factory(capabilities.cameraPrivacyMode.hardPrivacyMode, clusters.CameraAvStreamManagement.attributes.HardPrivacyModeOn),
         [clusters.CameraAvStreamManagement.attributes.TwoWayTalkSupport.ID] = attribute_handlers.two_way_talk_support_handler,
         [clusters.CameraAvStreamManagement.attributes.SpeakerMuted.ID] = attribute_handlers.muted_handler,
         [clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel.ID] = attribute_handlers.volume_level_handler,
@@ -166,8 +166,8 @@ local camera_handler = {
       [capabilities.mode.commands.setMode.NAME] = capability_handlers.handle_set_status_light_mode
     },
     [capabilities.switch.ID] = {
-      [capabilities.switch.commands.on.NAME] = capability_handlers.handle_status_led_on,
-      [capabilities.switch.commands.off.NAME] = capability_handlers.handle_status_led_off
+      [capabilities.switch.commands.on.NAME] = capability_handlers.handle_switch_on,
+      [capabilities.switch.commands.off.NAME] = capability_handlers.handle_switch_off
     },
     [capabilities.audioRecording.ID] = {
       [capabilities.audioRecording.commands.setAudioRecording.NAME] = capability_handlers.handle_audio_recording

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