Skip to main content

Plugin System

Home Assistant Matter Hub supports plugins that register additional Matter devices on the bridge. Plugins can provide virtual devices or integrate third-party services.

Installing a Plugin

From npm

  1. Open the Plugins page in the HAMH web UI
  2. Enter the npm package name (e.g., hamh-plugin-example)
  3. Click Install
  4. Restart the bridge to load the plugin

From a local .tgz file

Upload a packaged plugin via the API:

curl -X POST http://localhost:8482/api/plugins/upload \
-H "Content-Type: application/octet-stream" \
--data-binary @hamh-plugin-example-1.0.0.tgz

From a local folder (development)

Link a local plugin directory:

curl -X POST http://localhost:8482/api/plugins/install-local \
-H "Content-Type: application/json" \
-d '{"path": "/path/to/your/plugin"}'

This creates a symlink, so changes to your plugin source apply on bridge restart. Note that locally linked plugins are not added to the internal package.json dependencies, they rely on the symlink persisting. This method is intended for development only.

Writing a Plugin

A plugin is an npm package that exports a class implementing the MatterHubPlugin interface.

Minimal Structure

my-plugin/
package.json
index.js

package.json:

{
"name": "hamh-plugin-my-plugin",
"version": "1.0.0",
"main": "index.js",
"type": "module",
"hamhPluginApiVersion": 1
}

The hamhPluginApiVersion field declares which plugin API version your plugin targets. HAMH logs a warning if this doesn't match the current API version.

index.js:

export default class MyPlugin {
readonly name = "hamh-plugin-my-plugin";
readonly version = "1.0.0";

async onStart(context) {
await context.registerDevice({
id: "my-device-1",
name: "My Device",
deviceType: "temperature_sensor",
clusters: [
{
clusterId: "temperatureMeasurement",
attributes: { measuredValue: 2150 },
},
],
});
}

async onShutdown() {
// Clean up timers, connections, etc.
}
}

Plugin Lifecycle

HookWhenPurpose
onStart(context)Bridge startsRegister devices, set up connections
onConfigure()After all devices registeredRestore persistent state
onShutdown(reason?)Bridge stopsClean up resources
getConfigSchema()On demandProvide config UI schema
onConfigChanged(config)User updates configApply new configuration

PluginContext API

The context object passed to onStart provides:

  • registerDevice(device), Register a Matter device on the bridge
  • unregisterDevice(deviceId), Remove a previously registered device
  • updateDeviceState(deviceId, clusterId, attributes), Push attribute updates to a device
  • registerDomainMapping(mapping), Map an HA domain to a Matter device type (see Domain Mappings)
  • storage, Persistent key-value store (survives restarts)
  • log, Scoped logger (info, warn, error, debug)
  • bridgeId, ID of the bridge this plugin is attached to

Supported Device Types

KeyMatter Device
on_off_lightOn/Off Light (0x0100)
dimmable_lightDimmable Light (0x0101)
color_temperature_lightColor Temperature Light (0x0102)
extended_color_lightExtended Color Light (0x010D)
on_off_plugin_unitOn/Off Plug-in Unit (0x010A)
dimmable_plug_in_unitDimmable Plug-in Unit (0x010B)
temperature_sensorTemperature Sensor (0x0302)
humidity_sensorHumidity Sensor (0x0307)
pressure_sensorPressure Sensor (0x0305)
flow_sensorFlow Sensor (0x0306)
light_sensorLight Sensor (0x0106)
occupancy_sensorOccupancy Sensor (0x0107)
contact_sensorContact Sensor (0x0015)
air_quality_sensorAir Quality Sensor (0x002C)
thermostatThermostat (0x0301)
door_lockDoor Lock (0x000A)
fanFan (0x002B)
window_coveringWindow Covering (0x0202)
generic_switchGeneric Switch (0x000F)
water_leak_detectorWater Leak Detector (0x0043)

Custom endpoints (advanced)

If the device type or cluster you need is not in the table above, a plugin can supply its own matter.js EndpointType via endpointType instead of deviceType. This lets you expose any Matter device type, including ones with custom clusters and your own command handlers (a Behavior subclass), without changes to HAMH core.

import { OnOffLightDevice } from "@matter/main/devices";

const MyDeviceType = OnOffLightDevice.with(MyCustomBehavior); // your Behavior with command handlers

await context.registerDevice({
id: "my-device-1",
name: "My Device",
endpointType: MyDeviceType, // provide this OR deviceType, not both
clusters: [], // optional initial attribute state
});
matter.js instance matters

A live EndpointType only works if it comes from the exact same matter.js instance the backend runs. The backend bundles @matter/*, while externally installed plugins live in a separate folder and resolve their own copy, so a live endpointType from an external package will not attach. Plugins that pass live matter.js objects therefore ship as built-ins (see below). External plugins are best for the deviceType + cluster-data flow, which crosses the boundary as plain data.

Built-in plugins

Some device types need a live matter.js EndpointType (custom clusters and command handlers), which only works from inside the backend bundle. These ship as built-in plugins. They show up in the Plugins page like any other plugin and are configured there; nothing to install.

Plugins run on standard bridges only. A bridge with Server Mode enabled hosts no plugins at all, including the built-in camera; the Plugins page lists it with a note saying so. If every bridge you have is in Server Mode, create a standard bridge and put the camera there. Apple Home does not render Matter cameras; as of 2026 SmartThings is the only controller that does.

A built-in plugin registers no devices until it is actually configured: the camera waits for camera entity ids, the security plugin for at least one trigger list. Until then nothing appears on your controllers, and once you save a real config the devices mount without a bridge restart. Disabling a plugin removes its mounted devices right away and the choice is stored per bridge, so a disabled plugin stays disabled across restarts until you enable it again.

Camera exposes a Home Assistant camera as a Matter Camera (0x0142). It implements the Matter WebRtcTransportProvider flow and bridges HA's WebRTC. To configure it, open the Plugins page and click the camera plugin (or its gear icon). A settings dialog opens; fill in the cameras and save:

SettingDescription
camerasCamera entity ids, comma-separated, e.g. camera.front,camera.garage. This is the only field you need to set.
haUrlOptional. Leave empty to reuse the bridge's Home Assistant connection. Only set it to point at a different HA.
haTokenOptional. Leave empty to reuse the bridge's connection, or set a long-lived token to match a custom haUrl.

Plugin config is stored per bridge, so set the cameras on each bridge that should expose them. Upgrading from a build before this split resets the config once, so re-enter the cameras after updating.

SmartThings live view needs Matter over TCP: the WebRTC offer the camera sends is far larger than Matter's UDP message size, so the stream stalls over UDP alone. A bridge with cameras configured turns on a Matter TCP listener on the bridge's operational port automatically, both on start and after you save the camera list. Host networking already covers this; a strict firewall must allow TCP on the bridge port. The TCP capability is advertised to every controller on that bridge, so if another controller misbehaves with it, keep the cameras on a dedicated bridge.

Live view answers now travel back to the controller: after the camera computes its SDP answer, the bridge invokes the answer command on the controller's WebRtcTransportRequestor cluster over the same session, so the handshake completes instead of stalling with the answer discarded. The answer SDP already carries the camera's gathered ICE candidates, so no separate candidate trickle is sent from our side.

Experimental: the media path is delivered but not yet verified end to end on real hardware, and as of 2026 only SmartThings renders Matter cameras.

Security is experimental and turns the bridge into a small alarm system for setups that have no alarm integration. It registers four exclusive mode switches (Home, Away, Night, Vacation) and an Alarm contact sensor. Arming is turning a mode switch on, from any controller or by voice: "Alexa, turn on Away". Turning the active switch off disarms, and all switches off means disarmed; the plugin turns the other three off whenever one goes on. The Alarm sensor opens while the alarm is tripped, so controller automations can react to it. There is no PIN: anything that can flip the mode switches can also disarm, so expose them only to controllers you trust.

Arming waits out the exit delay, then the mode's setters are invoked (each entity gets its domain's turn_on, so scripts and scenes run and switches turn on). While armed, door/window/garage_door/opening binary sensors get the entry delay; every other trigger class trips instantly. A trip turns on the mode's alerts plus the Always list and opens the Alarm sensor. After the trigger time the alarm returns to the state it was tripped from (the armed mode, or disarmed for a 24h trip while disarmed) and siren/switch/light alerts get a turn_off (scripts and scenes do not); a trigger time of 0 keeps it tripped until disarm. A 24h trip while disarmed with a trigger time of 0 has no single Matter-side clear: arm any mode and disarm again to reset it. The armed state is persisted, so a restart mid-armed comes back armed. Trigger events during a Home Assistant connection gap are lost; the connection resubscribes on its own once HA is back. A silence or a mode's setters that hit such a gap are not lost: due turn_off calls are persisted until Home Assistant confirms them, and held setters are replayed on reconnect if that mode is still armed.

Every entity field is a comma-separated list, deduplicated on parse. Alert lists accept only siren, switch and light (turned off again when the alarm clears) plus script and scene (fire-only); entities from any other domain are dropped with a warning.

SettingDescription
exitDelaySecondsDelay before an armed mode takes effect. Default 60, 0 disables.
entryDelaySecondsDelay for perimeter (door/window/garage door/opening) sensors while armed. Default 60, 0 disables.
triggerTimeSecondsHow long the alarm stays tripped before returning to the state it was tripped from. Default 120, 0 keeps it tripped until disarm.
homeSetters / awaySetters / nightSettersEntities invoked when that mode is reached, comma-separated.
offSettersEntities invoked on disarm.
homeTriggers / awayTriggers / nightTriggersSensors that trip the alarm in that mode.
homeAlerts / awayAlerts / nightAlertsEntities turned on when the alarm trips in that mode. Siren/switch/light and script/scene only.
vacationSetters / vacationTriggers / vacationAlertsIndependent lists; left empty they use the Away lists.
triggers24hTrip the alarm in every state including disarmed, never with entry delay. Smoke, gas, water leak.
alerts24hEntities turned on when a 24h trigger trips. Same domains as the mode alerts.
alwaysAlertsMaster alert list, fired on every trip in addition to the tier's alerts.
haUrl / haTokenOptional. Set both to point the plugin at a different Home Assistant; one without the other is ignored with a warning. Left empty the plugin dials its own socket with the bridge's credentials (the bridge connection itself is not reused).

Known limits: changing haUrl/haToken while silences or setters are still pending can replay them against the new Home Assistant, and a silence that keeps failing is retried on every reconnect without a cap. Both are on the list for the next revision.

If you already run Alarmo or another alarm integration, keep using it and bridge its alarm_control_panel entity instead. This plugin runs its own state machine, so pointing both at the same sensors gives you two independent alarms that do not know about each other. The first version targets users without an alarm integration.

Cluster IDs

Use Matter.js behavior key names as cluster IDs. Common ones:

Cluster IDDescription
onOffOn/Off state
levelControlBrightness level
colorControlColor (hue/saturation/temperature)
pressureMeasurementPressure (in 0.1 kPa units)
flowMeasurementFlow rate (in 0.1 m³/h units)
windowCoveringWindow covering position and motion
temperatureMeasurementTemperature (in 0.01°C units)
relativeHumidityMeasurementRelative humidity (in 0.01% units)
booleanStateBinary state (open/closed)
occupancySensingOccupancy detection
fanControlFan speed and mode
doorLockLock state

Handling Controller Commands

When a Matter controller writes an attribute (e.g., turns a light on), your device's onAttributeWrite callback is called:

await context.registerDevice({
id: "my-light",
name: "My Light",
deviceType: "on_off_light",
clusters: [
{ clusterId: "onOff", attributes: { onOff: false } },
],
onAttributeWrite: async (clusterId, attribute, value) => {
if (clusterId === "onOff" && attribute === "onOff") {
console.log(`Light turned ${value ? "on" : "off"}`);
// Forward to your actual hardware/service
}
},
});

Persistent Storage

Use context.storage to persist data across restarts:

// Save
await context.storage.set("lastState", { temperature: 21.5 });

// Restore
const saved = await context.storage.get("lastState");

Plugin Config Schema

Plugins can provide a JSON-schema-like config for the UI:

getConfigSchema() {
return {
title: "My Plugin Config",
properties: {
pollingInterval: { type: "number", title: "Polling Interval (ms)" },
apiKey: { type: "string", title: "API Key" },
},
};
}

async onConfigChanged(config) {
this.pollingInterval = config.pollingInterval ?? 30000;
}

Domain Mappings

Plugins can register domain mappings to tell HAMH how to handle HA entity domains that are not natively supported. Call context.registerDomainMapping() during onStart:

async onStart(context) {
// Map all "number" entities to dimmable lights
context.registerDomainMapping({
domain: "number",
matterDeviceType: "dimmable_light",
});
}

The matterDeviceType must be one of the Supported Device Types. Plugin domain mappings are checked after user-configured overrides but before the built-in domain table, they only apply to domains that HAMH does not already handle.

If multiple plugins register the same domain, the last one wins (a warning is logged).

Cloud Provider / Device Source Plugins

Plugins can integrate external cloud services by discovering devices, polling for state, and forwarding controller commands. See examples/hamh-plugin-cloud-mock/ for a full working example that demonstrates:

  • Device discovery from an external API
  • Periodic polling for state changes
  • Forwarding Matter controller commands to the cloud API
  • Storing API tokens securely via context.storage (never logged)
  • Config schema for polling interval and credentials

Replace the MockCloudApi class with your real provider's SDK to build a production plugin.

Error Handling

Plugins run in-process with a safety wrapper:

  • Timeout: Each lifecycle call has a 10-second timeout
  • Circuit breaker: 3 consecutive failures auto-disable the plugin
  • Recovery: Use the Reset button in the Plugins UI to re-enable a disabled plugin
  • Unhandled rejections: Fire-and-forget promises from plugins are caught at the process level and logged without crashing HAMH

The bridge continues running even if a plugin fails. See examples/hamh-plugin-broken/ for a test plugin that exercises various failure modes.

Troubleshooting

ProblemSolution
Plugin not loading after installRestart the bridge, plugins load on startup
"Circuit breaker tripped"Check logs for the error, fix the issue, then click Reset
Device not appearing in controllerVerify deviceType is in the supported list above
Attribute updates ignoredEnsure clusterId matches a behavior key (e.g., onOff, not OnOff)
Plugin crashes on startCheck that onStart doesn't throw, wrap risky code in try/catch

API Reference

EndpointMethodDescription
/api/pluginsGETList installed packages and active plugins per bridge
/api/plugins/installPOSTInstall from npm ({ packageName })
/api/plugins/uploadPOSTInstall from uploaded .tgz (binary body)
/api/plugins/install-localPOSTLink local folder ({ path })
/api/plugins/uninstallPOSTUninstall package ({ packageName })
/api/plugins/:bridgeId/:pluginName/enablePOSTEnable a plugin
/api/plugins/:bridgeId/:pluginName/disablePOSTDisable a plugin
/api/plugins/:bridgeId/:pluginName/resetPOSTReset circuit breaker
/api/plugins/:bridgeId/:pluginName/config-schemaGETGet config schema
/api/plugins/:bridgeId/:pluginName/configPOSTUpdate config ({ config })