Skip to content

Matter camera: expose mechanical PTZ presets as routine actions #3276

Open

Description

@ldeora

Summary

The official Matter camera subdriver already supports Matter mechanical PTZ presets through the SmartThings mechanicalPanTiltZoom capability. With the updated SmartThings app, saved presets can now be displayed, created and selected in the camera device view.

However, those presets are not available as THEN actions in SmartThings routines. The driver exposes mechanicalPanTiltZoom.presets and handles moveToPreset, but it does not expose the standard cameraPreset capability used by the routine editor.

A test driver has confirmed that adding cameraPreset as an automation-only interface, while retaining mechanicalPanTiltZoom for the camera device view, makes the existing preset names selectable in routines without affecting the camera plugin or PTZ controls.

Environment / verification

Verified on October 10, 2026, using:

  • Device: Aqara Camera Hub G350 (Matter camera, device type 0x0142)
  • Camera endpoint: 2
  • App: Updated SmartThings app for Android
  • Driver: Test build based on the SmartThings matter-switch camera subdriver

The routine editor now shows “Kamera zu Wegpunkt drehen” (“Move camera to waypoint”) with the camera's saved presets, including “Standard” and “Preset 3”. The existing camera detail view, PTZ controls and preset-management interface remain functional.

The screenshots demonstrate that the named preset action is exposed and selectable in the routine editor:

Image Image

Current behavior

The camera subdriver already:

  • Converts Matter MPTZPresets into mechanicalPanTiltZoom.presets.
  • Implements mechanicalPanTiltZoom.moveToPreset(id) using Matter MPTZMoveToPreset.
  • Shows and manages presets through the existing matter-camera plugin.

But profiles/camera.yml does not declare cameraPreset or include it under deviceConfig.automation.actions. As a result, routines offer no action for selecting a saved preset.

Relevant handlers:

Expected behavior

For any Matter camera advertising mechanical presets, SmartThings should expose an action such as:

THEN → Camera → Move camera to waypoint → <saved preset name>

The routine should execute the corresponding Matter MPTZMoveToPreset command, while the camera's existing controls and preset-management interface remain unchanged.

Suggested code changes

  1. profiles/camera.yml

    • Add optional cameraPreset to the main component.
    • Include cameraPreset in deviceConfig.automation.actions.
    • Do not add cameraPreset to detailView. Keep mechanicalPanTiltZoom there and retain the existing matter-camera presentation/plugin.
  2. camera_utils/device_configuration.lua

    • Dynamically enable cameraPreset only for a Camera AV Settings User Level Management server supporting MECHANICAL_PRESETS, provided MPTZPresets is not known to be absent from the endpoint's AttributeList.
    • Preserve the existing mechanicalPanTiltZoom capability.
  3. camera_handlers/attribute_handlers.lua

    • Keep emitting mechanicalPanTiltZoom.presets as before.
    • Additionally map the received MPTZPresets entries to cameraPreset.presets, using string IDs, the original preset names and applicable PTZ values in data.
  4. camera_handlers/capability_handlers.lua

    • Implement cameraPreset.execute(id) by validating/converting the selected ID (1..255) and invoking Matter CameraAvSettingsUserLevelManagement.MPTZMoveToPreset on the appropriate endpoint.
    • Keep creation, update and removal of presets in the existing mechanicalPanTiltZoom interface.
  5. sub_drivers/camera/init.lua

    • Register the cameraPreset.execute capability handler.

The relevant profile addition is:

# main component capabilities
- id: cameraPreset
  version: 1
  optional: true

# deviceConfig.automation.actions
- component: main
  capability: cameraPreset
  version: 1

# Keep cameraPreset OUT of deviceConfig.detailView.
# The existing mechanicalPanTiltZoom detail view remains unchanged.

Minimal suggested implementation

The following snippets are adapted for the current official matter-switch camera subdriver. They implement the same capability/command mapping that was verified in the experimental G350 driver. The official branch does not currently provide our test driver's camera_utils.attribute_supported() / command_supported() helpers, so these snippets deliberately do not depend on them.

src/sub_drivers/camera/camera_utils/device_configuration.lua

Inside the CameraAvSettingsUserLevelManagement server-cluster branch in CameraDeviceConfiguration.match_profile(), replace the existing mechanical-PTZ feature condition with:

local clus_has_feature = function(feature_bitmap)
  return clusters.CameraAvSettingsUserLevelManagement.are_features_supported(
    feature_bitmap, ep_cluster.feature_map
  )
end

local feature = clusters.CameraAvSettingsUserLevelManagement.types.Feature
local mechanical_presets = clus_has_feature(feature.MECHANICAL_PRESETS)

if clus_has_feature(feature.MECHANICAL_PAN) or
   clus_has_feature(feature.MECHANICAL_TILT) or
   clus_has_feature(feature.MECHANICAL_ZOOM) or
   mechanical_presets then
  table.insert(main_component_capabilities, capabilities.mechanicalPanTiltZoom.ID)
end

if mechanical_presets then
  table.insert(main_component_capabilities, capabilities.cameraPreset.ID)
end

MECHANICAL_PRESETS is the relevant feature for this capability; a camera need not support every mechanical axis. The official branch currently has no cached AV Settings AttributeList helper for filtering MPTZPresets. Once that generic metadata check is available (see #3180), the capability can additionally be suppressed if the attribute is explicitly absent.

src/sub_drivers/camera/camera_handlers/attribute_handlers.lua

In ptz_presets_handler(), preserve the existing mechanicalPanTiltZoom.presets event and add a second list for cameraPreset:

function CameraAttributeHandlers.ptz_presets_handler(driver, device, ib, response)
  if not ib.data.elements then return end

  local presets = {}
  local camera_presets = {}

  for _, v in ipairs(ib.data.elements) do
    local preset = v.elements
    local pan, tilt, zoom = 0, 0, 1
    local preset_data = {}

    if camera_utils.feature_supported(device,
      clusters.CameraAvSettingsUserLevelManagement.ID,
      clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_PAN) then
      pan = preset.settings.elements.pan.value
      preset_data.pan = pan
    end

    if camera_utils.feature_supported(device,
      clusters.CameraAvSettingsUserLevelManagement.ID,
      clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_TILT) then
      tilt = preset.settings.elements.tilt.value
      preset_data.tilt = tilt
    end

    if camera_utils.feature_supported(device,
      clusters.CameraAvSettingsUserLevelManagement.ID,
      clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_ZOOM) then
      zoom = preset.settings.elements.zoom.value
      preset_data.zoom = zoom
    end

    table.insert(presets, {
      id = preset.preset_id.value,
      label = preset.name.value,
      pan = pan,
      tilt = tilt,
      zoom = zoom
    })

    table.insert(camera_presets, {
      id = tostring(preset.preset_id.value),
      name = preset.name.value,
      data = preset_data
    })
  end

  device:emit_event_for_endpoint(ib, capabilities.mechanicalPanTiltZoom.presets(presets))

  if device:supports_capability(capabilities.cameraPreset) then
    device:emit_event_for_endpoint(ib, capabilities.cameraPreset.presets(camera_presets))
  end
end

The string preset ID is intentional: it matches the cameraPreset schema, while Matter MPTZMoveToPreset requires the corresponding numeric ID. The data object contains only the mechanical axes actually supported by the camera.

src/sub_drivers/camera/camera_handlers/capability_handlers.lua

Add a handler alongside the existing mechanical preset handlers. Do not replace handle_move_to_preset().

function CameraCapabilityHandlers.handle_camera_preset_execute(driver, device, cmd)
  if not device:supports_capability(capabilities.cameraPreset, cmd.component) then
    return
  end

  local preset_id = tonumber(cmd.args.id)
  if preset_id == nil or preset_id % 1 ~= 0 or
     preset_id < 1 or preset_id > 255 then
    device.log.warn(string.format(
      "Ignoring cameraPreset.execute with invalid Matter preset ID: %s",
      tostring(cmd.args.id)
    ))
    return
  end

  local endpoint_id = device:component_to_endpoint(cmd.component)
  device:send(
    clusters.CameraAvSettingsUserLevelManagement.server.commands.MPTZMoveToPreset(
      device, endpoint_id, preset_id
    )
  )
end

The experimental driver additionally guards this invocation using its existing AcceptedCommandList helper. Upstream can use that helper when #3180 or equivalent metadata support is incorporated; it is not available in the current official main implementation.

src/sub_drivers/camera/init.lua

Register the new command handler alongside, rather than in place of, the existing mechanicalPanTiltZoom handlers:

[capabilities.cameraPreset.ID] = {
  [capabilities.cameraPreset.commands.execute.NAME] =
    capability_handlers.handle_camera_preset_execute
},

Required profile configuration

These four Lua changes must be paired with the profiles/camera.yml update described above: declare optional cameraPreset on main, and add it under deviceConfig.automation.actions. Do not add cameraPreset to detailView and do not remove mechanicalPanTiltZoom from it.

These snippets describe the adaptation to upstream, not a patch that has been compiled against upstream main. The installed experimental driver has verified the routine-editor presentation on the G350; executing a saved routine still needs an explicit confirmation test.

Affected files

  • profiles/camera.yml
  • src/sub_drivers/camera/camera_utils/device_configuration.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

  1. cameraPreset is enabled only when mechanical presets and the required Matter attribute are supported.
  2. A received MPTZPresets report updates both mechanicalPanTiltZoom.presets and cameraPreset.presets, preserving IDs and names.
  3. cameraPreset.execute("2") invokes MPTZMoveToPreset(2) on the camera endpoint; invalid IDs are ignored.
  4. Cameras without mechanical presets do not expose the capability.
  5. The existing camera detail view, PTZ controls and preset management remain unaffected.
  6. A saved SmartThings routine can select and execute a named preset.

Scope

This is a generic Matter camera enhancement, not a G350-specific workaround. No manufacturer fingerprint or proprietary command is needed: the standard Matter preset table and MPTZMoveToPreset command already provide the required functionality.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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