Repository navigation
Closure and Media Devices
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.
| 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.
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.
| 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.Movingconst 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: {
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()
},
},
}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: [],
},
}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.
| 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.
Transport controls — play, pause, and moving through the content.
| 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// '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: {
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)
},
},
}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,
},
}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| 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.
Volume is an ordinary levelControl level, the same cluster a dimmable light
uses for brightness — so the conversions are the ones you already know.
| 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)
}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}`)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)
},
},
}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) },
}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.
- Introduction
- Core Concepts
- Getting Started
- State Management
- Monitoring External Changes
- Error Handling
- Best Practices
- Advanced Patterns
- Customising Features