homebridge
Version:
HomeKit support for the impatient
508 lines โข 26.6 kB
JavaScript
/**
* Accessory Manager
*
* Handles registering/unregistering accessories, building custom behaviors,
* detecting cluster features, creating endpoint options, creating accessory parts,
* and restoring cached state.
*/
import { EventEmitter } from 'node:events';
import process from 'node:process';
import { Endpoint } from '@matter/main';
import { BridgedDeviceBasicInformationServer } from '@matter/main/behaviors';
import { PowerSourceServer } from '@matter/node/behaviors';
import { Logger } from '../../logger.js';
import { setRegistryManager } from '../behaviors/EndpointContext.js';
import { HomebridgeRvcCleanModeServer, HomebridgeServiceAreaServer } from '../behaviors/index.js';
import { applyWindowCoveringFeatures, CLUSTER_IDS, detectBehaviorFeatures, detectWindowCoveringFeatures, determineColorControlFeaturesFromHandlers, extractColorControlFeatures, extractLevelControlFeatures, extractThermostatFeatures, validateAccessoryRequiredFields, } from '../serverHelpers.js';
import { devices, MatterDeviceError, } from '../types.js';
import { stripVendorFromLabel } from '../utils.js';
import { CORE_CLUSTER_BEHAVIOR_MAP } from './BehaviorMap.js';
const log = Logger.withPrefix('Matter/Server');
export class AccessoryManager {
/**
* Register a single Matter accessory
* The first two arguments are unused, but kept to keep consistency with the HAP accessory registration function signature.
*/
async registerAccessory(_pluginIdentifier, _platformName, accessory, deps) {
const serverNode = deps.getServerNode();
const aggregator = deps.getAggregator();
if (!serverNode || (!deps.config.externalAccessory && !aggregator)) {
throw new MatterDeviceError('Matter server not started');
}
validateAccessoryRequiredFields(accessory);
if (deps.accessories.has(accessory.UUID)) {
const existing = deps.accessories.get(accessory.UUID);
throw new MatterDeviceError(`Matter accessory with UUID "${accessory.UUID}" is already registered.\n`
+ `Existing accessory: "${existing?.displayName}"\n`
+ `New accessory: "${accessory.displayName}"\n`
+ 'Each accessory must have a unique UUID. Use api.hap.uuid.generate() with a unique string.');
}
this.restoreCachedState(accessory, deps.accessoryCache);
if (deps.accessories.size >= 1000) {
throw new MatterDeviceError(`Cannot register Matter accessory "${accessory.displayName}": `
+ 'Maximum device limit reached (1000 devices).\n'
+ `Current registered devices: ${deps.accessories.size}`);
}
try {
let deviceType = accessory.deviceType;
const windowCoveringFeatures = detectWindowCoveringFeatures(accessory);
if (windowCoveringFeatures.length > 0) {
deviceType = applyWindowCoveringFeatures(deviceType, accessory, windowCoveringFeatures);
}
const features = this.detectClusterFeatures(accessory, deviceType);
const customBehaviors = await this.buildCustomBehaviors(accessory, deviceType, features);
if (customBehaviors.length > 0) {
deviceType = deviceType.with(...customBehaviors);
log.info(`Applied ${customBehaviors.length} custom behavior(s) to device type`);
}
if (!deps.config.externalAccessory) {
// Skip if device type already includes BridgedDeviceBasicInformation
// (e.g., BridgedNodeEndpoint used as a composed device container)
const hasBridgedInfo = deviceType.behaviors?.supported?.bridgedDeviceBasicInformation;
if (!hasBridgedInfo) {
deviceType = deviceType.with(BridgedDeviceBasicInformationServer);
}
log.debug(`Added BridgedDeviceBasicInformationServer to ${accessory.displayName}`);
}
const endpointOptions = this.createEndpointOptions(accessory, deps.config);
const endpoint = new Endpoint(deviceType, endpointOptions);
setRegistryManager(endpoint, deps.registryManager);
if (deps.config.debugModeEnabled) {
log.debug(`Created endpoint for ${accessory.displayName} with initial cluster states`);
}
if (deps.config.externalAccessory) {
await serverNode.add(endpoint);
log.debug(`Added ${accessory.displayName} as external accessory to ServerNode`);
}
else {
await aggregator.add(endpoint);
if (deps.config.debugModeEnabled) {
log.debug(`Added endpoint for ${accessory.displayName} to aggregator`);
}
}
this.registerAccessoryHandlers(accessory, deps);
const internalParts = await this.createAccessoryParts(accessory, endpoint, deps);
await this.finalizeAccessoryRegistration(accessory, endpoint, internalParts, deps);
}
catch (error) {
log.error(`Failed to register Matter accessory ${accessory.displayName}:`, error);
throw new MatterDeviceError(`Failed to register accessory: ${error}`);
}
}
/**
* Unregister a Matter accessory
*/
async unregisterAccessory(uuid, deps) {
const accessory = deps.accessories.get(uuid);
if (!accessory) {
log.debug(`Accessory ${uuid} not found or not registered`);
if (deps.accessoryCache && deps.accessoryCache.getCached(uuid)) {
log.debug(`Removing ${uuid} from cache`);
deps.accessoryCache.removeCached(uuid);
deps.accessoryCache.requestSave(deps.accessories);
}
return;
}
try {
if (accessory.endpoint && deps.getAggregator()) {
await accessory.endpoint.close();
log.debug(`Removed endpoint for ${accessory.displayName}`);
}
deps.accessories.delete(uuid);
log.info(`Unregistered Matter accessory: ${accessory.displayName} (${uuid})`);
await this.notifyPartsListChanged(deps);
if (deps.accessoryCache) {
deps.accessoryCache.removeCached(uuid);
deps.accessoryCache.requestSave(deps.accessories);
}
if (deps.getMonitoringEnabled() && process.send) {
const event = {
type: 'accessoryRemoved',
data: { uuid },
};
process.send({
id: "matterEvent" /* IpcOutgoingEvent.MATTER_EVENT */,
data: event,
});
}
}
catch (error) {
log.error(`Failed to unregister Matter accessory ${uuid}:`, error);
throw new MatterDeviceError(`Failed to unregister accessory: ${error}`);
}
}
/**
* Restore cached state for an accessory
*/
restoreCachedState(accessory, accessoryCache) {
if (accessoryCache && accessoryCache.hasCached(accessory.UUID)) {
const cached = accessoryCache.getCached(accessory.UUID);
if (cached?.clusters && accessory.clusters) {
for (const [clusterName, cachedAttrs] of Object.entries(cached.clusters)) {
if (!accessory.clusters[clusterName]) {
// Skip clusters that the accessory no longer declares
continue;
}
// Only restore attributes that the accessory's current definition includes
const currentAttrs = accessory.clusters[clusterName];
const filteredCached = {};
for (const key of Object.keys(cachedAttrs)) {
if (key in currentAttrs) {
filteredCached[key] = cachedAttrs[key];
}
}
accessory.clusters[clusterName] = {
...currentAttrs,
...filteredCached,
};
}
if (cached.context) {
accessory.context = cached.context;
}
log.info(`Restored cached state for Matter accessory: ${accessory.displayName}`);
}
}
}
/**
* Detect cluster features for an accessory
*/
detectClusterFeatures(accessory, deviceType) {
const windowCoveringFeatures = detectWindowCoveringFeatures(accessory);
let serviceAreaFeatures = null;
if (accessory.clusters?.serviceArea) {
const features = [];
if (accessory.clusters.serviceArea.supportedMaps) {
features.push('Maps');
}
if (accessory.clusters.serviceArea.progress !== undefined) {
features.push('ProgressReporting');
}
if (features.length > 0) {
serviceAreaFeatures = features;
log.info(`ServiceArea features will be enabled for ${accessory.displayName}: ${features.join(', ')}`);
}
}
let colorControlFeatures = null;
if (accessory.handlers?.colorControl) {
colorControlFeatures = detectBehaviorFeatures(deviceType, CLUSTER_IDS.COLOR_CONTROL, extractColorControlFeatures);
if (colorControlFeatures) {
colorControlFeatures = determineColorControlFeaturesFromHandlers(accessory.handlers.colorControl);
}
}
let thermostatFeatures = null;
if (accessory.handlers?.thermostat) {
thermostatFeatures = detectBehaviorFeatures(deviceType, CLUSTER_IDS.THERMOSTAT, extractThermostatFeatures);
}
// LevelControl: matter.js's public `LevelControlServer` inherits the
// Lighting+OnOff feature set from its internal `LevelControlBase` (see
// LevelControlServer.ts line ~20: `LevelControlBehavior.with(OnOff, Lighting)`).
// The `.for(LevelControl)` pattern that's supposed to reset features is a
// no-op because the raw `LevelControl` cluster doesn't declare
// `supportedFeatures`, so `syncFeatures` returns the base schema unchanged.
//
// Consequence: on a Pump endpoint (whose device-type requirements put
// LevelControl in `optional`, not `SupportedBehaviors`), attaching our
// behavior as-is leaves `features.lighting === true` at runtime. matter.js
// then picks the spec's `[LT]` branch for MinLevel ("constraint 1 to 254")
// and rejects `minLevel: 0` with a ValidationError, plus
// `initializeLighting()` emits "currentLevel/minLevel invalid" warnings.
//
// Fix: detect the device type's declared LevelControl features and apply
// them via `.with(...)`. When the device type declares nothing (Pump and
// similar), fall back to an EMPTY feature set so `.with()` explicitly
// strips the inherited Lighting/OnOff โ the `[!LT]` branch then applies,
// `minLevel: 0` is valid, and `initializeLighting()` is skipped.
//
// See homebridge#3905 and the matter.js Device Library spec ยง 5 (Pump).
let levelControlFeatures = null;
if (accessory.handlers?.levelControl) {
levelControlFeatures = detectBehaviorFeatures(deviceType, CLUSTER_IDS.LEVEL_CONTROL, extractLevelControlFeatures);
if (levelControlFeatures === null) {
levelControlFeatures = [];
log.debug(`[${accessory.displayName}] Device type declares no LevelControl requirement; stripping inherited Lighting via .with()`);
}
}
return {
windowCoveringFeatures,
serviceAreaFeatures,
colorControlFeatures,
thermostatFeatures,
levelControlFeatures,
};
}
/**
* Build custom behaviors for an accessory based on handlers
*/
async buildCustomBehaviors(accessory, deviceType, features) {
const customBehaviors = [];
if (!accessory.handlers) {
return customBehaviors;
}
log.debug(`[${accessory.displayName}] Has handlers: ${Object.keys(accessory.handlers).join(', ')}`);
// Handle RoboticVacuumCleaner optional clusters
if (deviceType.deviceType === devices.RoboticVacuumCleanerDevice.deviceType) {
const { RvcCleanModeServer, ServiceAreaServer } = devices.RoboticVacuumCleanerRequirements;
if (accessory.clusters?.rvcCleanMode) {
if (accessory.handlers?.rvcCleanMode) {
customBehaviors.push(HomebridgeRvcCleanModeServer);
log.info('Adding custom RvcCleanMode behavior with handlers');
}
else {
customBehaviors.push(RvcCleanModeServer);
log.info('Adding base RvcCleanMode server');
}
}
if (accessory.clusters?.serviceArea) {
if (accessory.handlers?.serviceArea) {
let behaviorClass = HomebridgeServiceAreaServer;
if (features.serviceAreaFeatures && features.serviceAreaFeatures.length > 0) {
behaviorClass = behaviorClass.with(...features.serviceAreaFeatures);
log.info(`ServiceArea custom behavior will have features: ${features.serviceAreaFeatures.join(', ')}`);
}
customBehaviors.push(behaviorClass);
log.info('Adding custom ServiceArea behavior with handlers');
}
else {
let behaviorClass = ServiceAreaServer;
if (features.serviceAreaFeatures && features.serviceAreaFeatures.length > 0) {
behaviorClass = behaviorClass.with(...features.serviceAreaFeatures);
log.info(`ServiceArea base server will have features: ${features.serviceAreaFeatures.join(', ')}`);
}
customBehaviors.push(behaviorClass);
log.info('Adding base ServiceArea server');
}
}
if (accessory.clusters?.powerSource) {
const hasBattery = accessory.clusters.powerSource.batPercentRemaining !== undefined
|| accessory.clusters.powerSource.batChargeLevel !== undefined;
const hasRechargeable = accessory.clusters.powerSource.batChargeState !== undefined;
let powerSourceBehavior = PowerSourceServer;
if (hasBattery && hasRechargeable) {
powerSourceBehavior = PowerSourceServer.with('Battery', 'Rechargeable');
log.debug('Adding PowerSource server with battery and rechargeable features');
}
else if (hasBattery) {
powerSourceBehavior = PowerSourceServer.with('Battery');
log.debug('Adding PowerSource server with battery feature');
}
else {
log.debug('Adding base PowerSource server');
}
customBehaviors.push(powerSourceBehavior);
}
}
for (const clusterName of Object.keys(accessory.handlers || {})) {
const skipWindowCoveringBehavior = accessory.context?._skipWindowCoveringBehavior;
if (clusterName === 'windowCovering' && skipWindowCoveringBehavior) {
log.debug('Skipping custom WindowCovering behavior (using base server with features instead)');
continue;
}
if (clusterName === 'rvcCleanMode' || clusterName === 'serviceArea' || clusterName === 'powerSource') {
continue;
}
let behaviorClass = CORE_CLUSTER_BEHAVIOR_MAP[clusterName];
if (clusterName === 'colorControl' && behaviorClass && features.colorControlFeatures && features.colorControlFeatures.length > 0) {
behaviorClass = behaviorClass.with(...features.colorControlFeatures);
log.info(`ColorControl custom behavior will preserve features: ${features.colorControlFeatures.join(', ')}`);
}
if (clusterName === 'thermostat' && behaviorClass && features.thermostatFeatures && features.thermostatFeatures.length > 0) {
behaviorClass = behaviorClass.with(...features.thermostatFeatures);
log.info(`Thermostat custom behavior will preserve features: ${features.thermostatFeatures.join(', ')}`);
}
// LevelControl: unlike the branches above, we apply `.with(...)` even when
// the feature array is empty. That's deliberate โ an empty feature set is
// what strips the Lighting/OnOff features HomebridgeLevelControlServer
// inherits from matter.js's LevelControlBase. See detectClusterFeatures()
// above for the full explanation.
if (clusterName === 'levelControl' && behaviorClass && features.levelControlFeatures !== null) {
behaviorClass = behaviorClass.with(...features.levelControlFeatures);
if (features.levelControlFeatures.length > 0) {
log.info(`LevelControl custom behavior will preserve features: ${features.levelControlFeatures.join(', ')}`);
}
else {
log.debug('LevelControl custom behavior applied with empty feature set (strips inherited Lighting)');
}
}
if (clusterName === 'serviceArea' && behaviorClass && features.serviceAreaFeatures && features.serviceAreaFeatures.length > 0) {
behaviorClass = behaviorClass.with(...features.serviceAreaFeatures);
log.info(`ServiceArea custom behavior will preserve features: ${features.serviceAreaFeatures.join(', ')}`);
}
if (clusterName === 'windowCovering') {
log.debug(`WindowCovering handler found: behaviorClass=${!!behaviorClass}, windowCoveringFeatures=${features.windowCoveringFeatures}, length=${features.windowCoveringFeatures?.length}`);
if (behaviorClass && features.windowCoveringFeatures && features.windowCoveringFeatures.length > 0) {
behaviorClass = behaviorClass.with(...features.windowCoveringFeatures);
log.debug(`WindowCovering custom behavior will have features: ${features.windowCoveringFeatures.join(', ')}`);
}
else {
log.debug(`Skipping WindowCovering feature application: behaviorClass=${!!behaviorClass}, features=${features.windowCoveringFeatures}`);
}
}
if (behaviorClass) {
customBehaviors.push(behaviorClass);
log.info(`Will use ${behaviorClass.name} for ${accessory.displayName}`);
}
else {
log.warn(`No custom behavior class available for cluster '${clusterName}' - handlers will be registered but may not be called`);
}
}
return customBehaviors;
}
/**
* Create endpoint options for an accessory
*/
createEndpointOptions(accessory, config) {
const endpointOptions = {
id: accessory.UUID,
...accessory.clusters,
};
if (!config.externalAccessory) {
endpointOptions.bridgedDeviceBasicInformation = {
vendorName: accessory.manufacturer,
nodeLabel: accessory.displayName,
productName: accessory.model,
// productLabel SHALL NOT include the vendor name per the Matter spec.
// Fall back to model or "Device" when stripping consumes the whole name.
productLabel: stripVendorFromLabel(accessory.displayName, accessory.manufacturer)
|| accessory.model || 'Device',
serialNumber: accessory.serialNumber,
reachable: true,
};
}
return endpointOptions;
}
/**
* Register command handlers for an accessory
*/
registerAccessoryHandlers(accessory, deps) {
if (!accessory.handlers) {
return;
}
log.info(`Setting up handlers for accessory ${accessory.UUID}`);
deps.registryManager.registerEndpoint(accessory.UUID, deps.behaviorRegistry);
for (const [clusterName, handlers] of Object.entries(accessory.handlers)) {
log.info(` Processing cluster: ${clusterName}`);
for (const [commandName, handler] of Object.entries(handlers)) {
deps.behaviorRegistry.registerHandler(accessory.UUID, clusterName, commandName, handler);
}
}
}
/**
* Create and register child endpoints (parts) for an accessory
*
* Parts are added as sub-endpoints of the parent endpoint, creating a composed
* device per the Matter spec. Children are plain device types with no
* BridgedDeviceBasicInformation โ only the parent has that.
* See: https://github.com/matter-js/matter.js/blob/main/docs/MIGRATION_GUIDE_08.md
*/
async createAccessoryParts(accessory, parentEndpoint, deps) {
const internalParts = [];
if (!accessory.parts || accessory.parts.length === 0) {
return internalParts;
}
log.info(`Creating ${accessory.parts.length} child endpoint(s) for ${accessory.displayName}`);
for (const part of accessory.parts) {
const partEndpointId = `${accessory.UUID}-part-${part.id}`;
deps.behaviorRegistry.registerPartEndpoint(partEndpointId, accessory.UUID, part.id);
let partDeviceType = part.deviceType;
const partCustomBehaviors = [];
if (part.handlers) {
for (const clusterName of Object.keys(part.handlers)) {
const behaviorClass = CORE_CLUSTER_BEHAVIOR_MAP[clusterName];
if (behaviorClass) {
partCustomBehaviors.push(behaviorClass);
log.info(` Will use ${behaviorClass.name} for part ${part.id}`);
}
else {
log.warn(`No custom behavior class available for cluster '${clusterName}' on part ${part.id}`);
}
}
if (partCustomBehaviors.length > 0) {
partDeviceType = partDeviceType.with(...partCustomBehaviors);
log.info(` Applied ${partCustomBehaviors.length} custom behavior(s) to part ${part.id}`);
}
}
const partEndpointOptions = {
id: partEndpointId,
...part.clusters,
};
const partEndpoint = new Endpoint(partDeviceType, partEndpointOptions);
setRegistryManager(partEndpoint, deps.registryManager);
await parentEndpoint.add(partEndpoint);
log.info(` Created part endpoint: ${part.displayName || part.id} (${partEndpointId}) as child of ${accessory.displayName}`);
if (part.handlers) {
deps.registryManager.registerEndpoint(partEndpointId, deps.behaviorRegistry);
for (const [clusterName, handlers] of Object.entries(part.handlers)) {
for (const [commandName, handler] of Object.entries(handlers)) {
deps.behaviorRegistry.registerHandler(partEndpointId, clusterName, commandName, handler);
}
}
log.debug(` Registered ${Object.keys(part.handlers).length} handler(s) for part ${part.id}`);
}
internalParts.push({
...part,
endpoint: partEndpoint,
});
}
return internalParts;
}
/**
* Finalize accessory registration (store, emit events, save cache)
*/
async finalizeAccessoryRegistration(accessory, endpoint, internalParts, deps) {
const internalAccessory = {
...accessory,
endpoint,
registered: true,
_parts: internalParts.length > 0 ? internalParts : undefined,
_eventEmitter: new EventEmitter(),
};
deps.accessories.set(accessory.UUID, internalAccessory);
log.info(`Registered Matter accessory: ${accessory.displayName} (${accessory.UUID})`);
if (deps.config.debugModeEnabled) {
log.debug(`Total registered accessories: ${deps.accessories.size}/1000`);
}
await this.notifyPartsListChanged(deps);
if (deps.accessoryCache) {
deps.accessoryCache.requestSave(deps.accessories);
}
if (deps.getMonitoringEnabled() && process.send) {
const event = {
type: 'accessoryAdded',
data: { uuid: accessory.UUID },
};
process.send({
id: "matterEvent" /* IpcOutgoingEvent.MATTER_EVENT */,
data: event,
});
}
}
/**
* Notify controllers that the parts list has changed
*/
async notifyPartsListChanged(deps) {
const aggregator = deps.getAggregator();
if (!aggregator || !deps.isCommissioned()) {
return;
}
try {
const aggregatorState = aggregator;
if (aggregatorState.state?.descriptor) {
const partsList = aggregatorState.state.descriptor.partsList || [];
if (deps.config.debugModeEnabled) {
log.debug(`Parts list changed: ${partsList.length} devices (endpoints: ${partsList.join(', ')})`);
}
await aggregator.set({
descriptor: {
partsList,
},
});
log.info(`Notified controllers of parts list change (${deps.accessories.size} devices)`);
}
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
log.warn(`Failed to notify controllers of parts list change: ${errorMessage}`);
}
}
}
//# sourceMappingURL=AccessoryManager.js.map