Endpoint Management in Bridges
Each bridge exposes child endpoints that represent Home Assistant entities. This page walks through how those endpoints are built, updated, and torn down.
The mapping pipeline
The legacy endpoint factory lives in packages/backend/src/matter/endpoints/legacy/. It maps one HA entity to one Matter endpoint by way of a per-domain dispatcher:
HA entity
→ createLegacyEndpointType() (entry point)
→ dispatches on HomeAssistantDomain
→ legacy/<domain>/index.ts (per-domain builder)
→ Matter device type + behavior servers
→ .set({ homeAssistantEntity })
createLegacyEndpointType() reads the entity's domain, looks up the domain's builder function, and returns the composed endpoint type. The builder decides which Matter device type fits (e.g. OnOffLight vs DimmableLight based on HA's supported_color_modes), then uses matter.js's .with(...) to compose behavior servers onto the device type.
Behavior servers wrap matter.js cluster servers and pull values from the HA entity via HomeAssistantEntityBehavior, the shared behavior every legacy endpoint carries. Each behavior server follows the same pattern:
class MyServer extends Base {
override async initialize() {
await super.initialize();
const ha = await this.agent.load(HomeAssistantEntityBehavior);
this.update(ha.entity);
this.reactTo(ha.onChange, this.update);
}
private update(entity: HomeAssistantEntityInformation) {
// read entity.state / entity.state.attributes
// write to this.state via applyPatchState()
}
}
applyPatchState is the project's wrapper around matter.js state writes. It only writes fields that actually changed, and it swallows a small set of matter.js lifecycle errors (DestroyedDependencyError, transaction conflicts) that fire during shutdown.
BridgeEndpointManager
packages/backend/src/services/bridges/bridge-endpoint-manager.ts owns the root aggregator endpoint and the set of child endpoints that hang off it.
Responsibilities:
- Build endpoints from the filtered entity list (
BridgeRegistry.includedEntities) and plug devices. - Refresh when the HA registry changes, create missing endpoints, delete removed ones, rewire mapped companion sensors.
- Forward state updates from HA (via
subscribeEntities) into matter.js via each endpoint'supdateStates(). - Serialize state bursts so that when HA fires 200 state updates in 50 ms (restart, scene activation), matter.js only sees one batch at a time, the manager keeps a pending batch and collapses consecutive calls.
- Track plugin endpoints separately: listeners on plugin cluster events are kept so they can be detached when the plugin removes a device.
Typical call site in start-handler.ts:
const manager = new BridgeEndpointManager(client, registry, mappingStorage, bridgeId, log);
manager.startObserving();
HomeAssistantRegistry.enableAutoRefresh
HomeAssistantRegistry polls HA's entity / device / label / area registries on a timer (default 60 s). enableAutoRefresh(callback) wires up the interval; the callback runs only when the registry fingerprint actually changed.
enableAutoRefresh = initBridges
.then(() => registry$)
.then((r) => r.enableAutoRefresh(() => bridgeService.refreshAll()));
The callback is guarded against overlapping runs, if the previous tick is still retrying (slow HA, reconnect), the next tick skips instead of stacking up.
Endpoint update flow
- HA entity state changes.
subscribeEntitiesfires a batch delivery.BridgeEndpointManager.updateStates(states)gets called.- If no update is in flight, it runs
runUpdateStatesimmediately. If one is running, it stashes the newest batch and lets the running call pick it up when done. runUpdateStatesmerges the batch into the registry and dispatchesendpoint.updateStates(states)to every child endpoint in parallel.- Each
LegacyEndpoint.updateStatescompares the entity against its last cached state, state string plus a deep-equal attribute check, and returns early if nothing changed, so the matter.js cluster writes don't fire when they don't need to.
Full registry-refresh flow
- Interval fires on
HomeAssistantRegistry.enableAutoRefresh. reload()retriesfetchRegistries()up to 10 times against WS timeouts.- All five HA calls (
config/entity_registry/list,getStates,config/device_registry/list,config/label_registry/list,config/area_registry/list) fire in parallel with a 30 s timeout each viasendHaMessage. - The registry computes an MD5 fingerprint over structural entity/device/state metadata. Unchanged → skip.
- Changed → rebuild the registry, invoke the
onRefreshcallback, which reachesBridgeService.refreshAll()→ each bridge'srefreshDevices().
Where to start when adding a new domain
- Create
packages/backend/src/matter/endpoints/legacy/<domain>/index.tswith a builder function that returns anEndpointType. - Pick the matter.js device type (
@matter/main/devices) and the behavior servers (@matter/main/behaviorsplus any HAMH-local ones inpackages/backend/src/matter/behaviors/). - If the domain maps onto a new cluster, add an enum entry to
packages/common/src/clusters/index.ts, the cluster-validation test increate-legacy-endpoint-type.test.tsasserts every cluster ID a HAMH endpoint exposes is in that enum. - Wire the domain into
createLegacyEndpointType(). - Add controller-compatibility rows to
docs-site/docs/guides/controller-compatibility.md. Every cell starts as❓until a vendor doc or pair-test proves otherwise.