Skip to content

Closure and Media Devices

Ben edited this page Nov 1, 2026 · 1 revision

Closure, Media Player and Speaker

Three device types added to api.matter?.deviceTypes in Homebridge v2.3.0.

Warning

Apple Home does not support any of these three yet. A plugin can register them and they will pair, but nothing appears in the Home app. They are useful today for Google Home and Alexa, and for having the work already done when Apple catches up. Everything below is written on that basis.


Closure

Property Value
Device Type api.matter?.deviceTypes.Closure
Description A powered opening — a garage door, gate, shutter or similar — that travels between closed and open.
Matter Specification § 8.5 (Closure), device type 0x0230

Important

Closure is not the same thing as Window Covering. Window Covering (§ 8.3) reports a position as a percentage and is the right choice for blinds and curtains. Closure reports a named position — fully closed, fully open, partially open — which is what a garage door actually has. If you want a percentage, you want Window Covering.

ClosureControl Cluster

Controls and reports the state of the opening.

Warning

This cluster is feature-gated. Homebridge composes the type with the Positioning feature already applied, which is what makes the position attributes exist at all. A bare ClosureDevice carries nothing but Identify, and every state you set on it is silently dropped — no error, no log line, just an endpoint that does nothing. If you compose your own variant (see below), you must apply the features yourself.

Attributes

Attribute Type Range/Values Description
mainState number 0-7 What the closure is doing right now (see below)
overallCurrentState object|null — Where it actually is: { position, latch, speed, secureState }
overallTargetState object|null — Where it is heading: { position, latch, speed }
countdownTime number|null — Seconds until the current movement finishes, if known
currentErrorList array — Errors currently active on the device

Main State Values:

Value State Description
0 Stopped Not moving
1 Moving Travelling towards the target position
2 WaitingForMotion Accepted the command, has not started yet
3 Error Something is wrong; see currentErrorList
4 Calibrating Learning its travel limits
5 Protected Movement blocked by a protection feature
6 Disengaged Motor disengaged, e.g. for manual operation
7 SetupRequired Needs commissioning before it will move

Current Position Values (the position field of overallCurrentState):

Value Position Description
0 FullyClosed Shut
1 FullyOpened Fully open
2 PartiallyOpened Somewhere in between
3 OpenedForPedestrian At the pedestrian position
4 OpenedForVentilation At the ventilation position
5 OpenedAtSignature At the signature position

Target Position Values (the position field of overallTargetState, and of a moveTo request):

Value Position
0 MoveToFullyClosed
1 MoveToFullyOpen
2 MoveToPedestrianPosition
3 MoveToVentilationPosition
4 MoveToSignaturePosition

Note that the two lists are not the same enum. Current position describes where the closure is; target position describes where it has been asked to go. FullyOpened is 1 in one and MoveToFullyOpen is 1 in the other, but do not rely on them lining up beyond that — convert deliberately.

Access both via api.matter?.types:

api.matter?.types.ClosureControl.CurrentPosition.FullyClosed
api.matter?.types.ClosureControl.TargetPosition.MoveToFullyOpen
api.matter?.types.ClosureControl.MainState.Moving

Reading State

const closure = await api.matter!.getAccessoryState(uuid, 'closureControl')
const stateNames = [
  'Stopped', 'Moving', 'WaitingForMotion', 'Error',
  'Calibrating', 'Protected', 'Disengaged', 'SetupRequired',
]

log.info(`Closure is ${stateNames[closure?.mainState ?? 0]}`)

const position = closure?.overallCurrentState?.position
if (position !== undefined && position !== null) {
  const positionNames = [
    'closed', 'open', 'part open',
    'pedestrian', 'ventilation', 'signature',
  ]
  log.info(`Door is ${positionNames[position]}`)
}

Handlers

handlers: {
  closureControl: {
    /**
     * Called when the closure is asked to move somewhere
     */
    moveTo: async (request) => {
      const { position } = request

      switch (position) {
        case api.matter?.types.ClosureControl.TargetPosition.MoveToFullyOpen:
          log.info('Opening the door')
          await myDoorAPI.open()
          break
        case api.matter?.types.ClosureControl.TargetPosition.MoveToFullyClosed:
          log.info('Closing the door')
          await myDoorAPI.close()
          break
        default:
          // Pedestrian, ventilation and signature positions only exist if you
          // composed the type with those features - see below
          log.warn(`Position ${position} is not supported by this door`)
      }
    },

    /**
     * Called when the closure is asked to stop where it is
     */
    stop: async () => {
      log.info('Stopping the door')
      await myDoorAPI.stop()
    },
  },
}

Updating State

A garage door is slow, so report the intermediate states rather than jumping straight to the end. Without Moving, the controller shows the door as shut until the moment it is fully open.

async function updateClosure(
  mainState: number,
  position: number | null,
) {
  await api.matter?.updateAccessoryState(
    uuid,
    api.matter?.clusterNames.ClosureControl,
    {
      mainState,
      overallCurrentState: { position, latch: null, secureState: null },
    },
  )
}

const { MainState, CurrentPosition } = api.matter!.types.ClosureControl

// Door has started opening
await updateClosure(MainState.Moving, CurrentPosition.PartiallyOpened)

// Door has finished opening
await updateClosure(MainState.Stopped, CurrentPosition.FullyOpened)

Initial State:

clusters: {
  closureControl: {
    mainState: api.matter?.types.ClosureControl.MainState.Stopped,
    overallCurrentState: {
      position: api.matter?.types.ClosureControl.CurrentPosition.FullyClosed,
      latch: null,
      secureState: null,
    },
    overallTargetState: null,
    countdownTime: null,
    currentErrorList: [],
  },
}

Composing your own variant

Homebridge applies only Positioning, because that is the feature set a garage door or gate needs. The cluster has others, and if your device supports them you can build the type yourself:

Feature What it adds
Positioning Named positions — applied by default
MotionLatching A latch that engages separately from the movement
Instantaneous Movement completes immediately, with no travel time
Speed A selectable movement speed
Ventilation The ventilation position
Pedestrian The pedestrian position
Calibration The calibrate command
Protection The Protected main state
ManuallyOperable The Disengaged main state
const VentilatingClosure = api.matter.deviceTypes.Closure.with(
  api.matter.deviceRequirements.Closure.ClosureControlServer.with('Positioning', 'Ventilation'),
)

deviceRequirements needs Homebridge v2.4.0 or later. Give a composed closure closureControl handlers: matter.js's own server leaves every command unimplemented, and Homebridge only swaps in its handler-calling version when handlers are supplied. See Customising Features.


Media Player

Property Value
Device Type api.matter?.deviceTypes.MediaPlayer
Description Anything that plays media — a TV, a streaming box, a radio, a speaker system.
Matter Specification § 10.2 (Basic Video Player), device type 0x0028

Note

The name says video, but nothing in it is video-only. Matter calls this type Basic Video Player; it carries transport controls and key input, both of which apply perfectly well to an audio-only device. If you are bridging a speaker or a radio, this is still the right type.

Important

Volume and mute are not part of this type. In Matter they live on a separate Speaker endpoint, composed alongside the player under the same bridged accessory. If you want play/pause and volume, you need both.

Clusters: onOff, mediaPlayback, keypadInput.

MediaPlayback Cluster

Transport controls — play, pause, and moving through the content.

Attributes

Attribute Type Range/Values Description
currentState number 0-3 What the player is doing (see below)
startTime number|null — When the current content started, if known
duration number|null — Length of the current content in milliseconds
sampledPosition object|null — Playback position, with the time it was sampled
playbackSpeed number — 1 is normal speed, 0 is paused, negative is rewind

Playback State Values:

Value State Description
0 Playing Content is playing
1 Paused Paused part-way through
2 NotPlaying Stopped, or nothing loaded
3 Buffering Waiting for more content to arrive
api.matter?.types.MediaPlayback.PlaybackState.Playing

Reading State

// 'player' is the part ID - see 'Putting a player and a speaker together' below
const playback = await api.matter!.getAccessoryState(uuid, 'mediaPlayback', 'player')
const stateNames = ['Playing', 'Paused', 'NotPlaying', 'Buffering']

log.info(`Player is ${stateNames[playback?.currentState ?? 2]}`)

Handlers

handlers: {
  mediaPlayback: {
    play: async () => {
      log.info('Play')
      await myPlayerAPI.play()
    },

    pause: async () => {
      log.info('Pause')
      await myPlayerAPI.pause()
    },

    stop: async () => {
      log.info('Stop')
      await myPlayerAPI.stop()
    },

    next: async () => {
      await myPlayerAPI.nextTrack()
    },

    previous: async () => {
      await myPlayerAPI.previousTrack()
    },

    startOver: async () => {
      await myPlayerAPI.seek(0)
    },
  },

  keypadInput: {
    /**
     * A remote-control key press, as a CEC key code
     */
    sendKey: async (request) => {
      const { keyCode } = request
      log.info(`Key pressed: ${keyCode}`)
      await myPlayerAPI.sendKey(keyCode)
    },
  },
}

Updating State

async function updatePlaybackState(state: number) {
  await api.matter?.updateAccessoryState(
    uuid,
    api.matter?.clusterNames.MediaPlayback,
    { currentState: state },
    'player',
  )
}

const { PlaybackState } = api.matter!.types.MediaPlayback

await updatePlaybackState(PlaybackState.Playing)
await updatePlaybackState(PlaybackState.Paused)

Initial State:

clusters: {
  onOff: { onOff: false },
  mediaPlayback: {
    currentState: api.matter?.types.MediaPlayback.PlaybackState.NotPlaying,
  },
}

KeypadInput Cluster

Carries remote-control key presses as CEC key codes. It has no attributes — it is a command-only cluster, so there is no state to initialise or update. Only the sendKey handler above.

Common key codes are available via api.matter?.types.KeypadInput.CecKeyCode:

api.matter?.types.KeypadInput.CecKeyCode.Select
api.matter?.types.KeypadInput.CecKeyCode.Up
api.matter?.types.KeypadInput.CecKeyCode.Down
api.matter?.types.KeypadInput.CecKeyCode.Exit
api.matter?.types.KeypadInput.CecKeyCode.RootMenu

Speaker

Property Value
Device Type api.matter?.deviceTypes.Speaker
Description Volume and mute for an audio output.
Matter Specification § 10.4 (Speaker), device type 0x0022

Clusters: onOff, levelControl.

Important

onOff here is mute, not power. On this device type onOff: false means muted. It does not turn the speaker off. Wiring it to a power command is the easiest mistake to make with this type.

LevelControl Cluster (volume)

Volume is an ordinary levelControl level, the same cluster a dimmable light uses for brightness — so the conversions are the ones you already know.

Attributes

Attribute Type Range/Values Description
currentLevel number 1-254 Current volume
minLevel number 1 Lowest volume
maxLevel number 254 Highest volume

Value Conversions:

// Volume percentage (0-100) to Matter level (1-254)
function volumeToMatter(percent: number): number {
  return Math.max(1, Math.round((percent / 100) * 254))
}

// Matter level (1-254) to volume percentage (0-100)
function matterToVolume(level: number): number {
  return Math.round((level / 254) * 100)
}

Reading State

const level = (await api.matter!.getAccessoryState(uuid, 'levelControl', 'speaker'))?.currentLevel ?? 0
log.info(`Volume: ${matterToVolume(level)}%`)

const muted = !(await api.matter!.getAccessoryState(uuid, 'onOff', 'speaker'))?.onOff
log.info(`Muted: ${muted}`)

Handlers

import type { MatterRequests } from 'homebridge'

handlers: {
  levelControl: {
    moveToLevel: async (request: MatterRequests.MoveToLevel) => {
      const percent = matterToVolume(request.level)
      log.info(`Setting volume to ${percent}%`)
      await mySpeakerAPI.setVolume(percent)
    },
  },

  onOff: {
    // Remember: on this device type, off means muted
    on: async () => {
      log.info('Unmuting')
      await mySpeakerAPI.setMuted(false)
    },

    off: async () => {
      log.info('Muting')
      await mySpeakerAPI.setMuted(true)
    },
  },
}

Updating State

async function updateVolume(percent: number) {
  await api.matter?.updateAccessoryState(
    uuid,
    api.matter?.clusterNames.LevelControl,
    { currentLevel: volumeToMatter(percent) },
    'speaker',
  )
}

async function updateMuted(muted: boolean) {
  await api.matter?.updateAccessoryState(
    uuid,
    api.matter?.clusterNames.OnOff,
    { onOff: !muted },
    'speaker',
  )
}

Initial State:

clusters: {
  onOff: { onOff: true },        // true = not muted
  levelControl: { currentLevel: volumeToMatter(50) },
}

Putting a player and a speaker together

In Matter, the player and the speaker are two endpoints of one device. Register one accessory with the BridgedNode device type, and give it the player and the speaker as parts - see Custom Devices for how parts work.

await api.matter?.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [
  {
    UUID: api.matter!.uuid.generate(deviceId),
    displayName: deviceName,
    deviceType: api.matter!.deviceTypes.BridgedNode,
    serialNumber: deviceId,
    manufacturer: 'Acme',
    model: 'Player-1',
    context: { deviceId },
    parts: [
      {
        id: 'player',
        displayName: `${deviceName} Player`,
        deviceType: api.matter!.deviceTypes.MediaPlayer,
        clusters: {
          onOff: { onOff: false },
          mediaPlayback: {
            currentState: api.matter!.types.MediaPlayback.PlaybackState.NotPlaying,
          },
        },
        handlers: { /* as above */ },
      },
      {
        id: 'speaker',
        displayName: `${deviceName} Speaker`,
        deviceType: api.matter!.deviceTypes.Speaker,
        clusters: {
          onOff: { onOff: true },
          levelControl: { currentLevel: volumeToMatter(50) },
        },
        handlers: { /* as above */ },
      },
    ],
  },
])

Pass the part ID ('player' or 'speaker') when you read or update state.

Clone this wiki locally