UNPKG

homebridge

Version:
508 lines โ€ข 26.6 kB
/** * 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