UNPKG

homebridge

Version:
425 lines • 19.2 kB
/** * Matter.js Server Implementation for Homebridge Plugin API * * This is a thin facade that delegates to focused submodules under ./server/. * All public method signatures are preserved for external callers. */ import { EventEmitter } from 'node:events'; import { Logger as MatterLogger, LogLevel as MatterLogLevel } from '@matter/main'; import { Logger } from '../logger.js'; import { BehaviorRegistry, RegistryManager } from './behaviors/index.js'; import { createHomebridgeLogFormatter } from './logFormatter.js'; import { AccessoryManager, AccessoryQuery, CommissioningManager, FabricManager, ServerLifecycle, StateManager, validateAndSanitizeConfig, } from './server/index.js'; import { clusters, deviceTypes, } from './types.js'; const log = Logger.withPrefix('Matter/Server'); const NON_HEX_RE = /[^A-F0-9]/gi; const HEX_PAIR_RE = /.{1,2}/g; /** * Matter Server for Homebridge Plugin API * Allows plugins to register Matter accessories explicitly */ export class MatterServer extends EventEmitter { // --- Internal state --- config; serverNode = null; aggregator = null; accessories = new Map(); behaviorRegistry; registryManager; isRunning = false; lastRegistrationAt = 0; registrationsInFlight = 0; shutdownHandler = null; cleanupHandlers = []; accessoryCache = null; monitoringEnabled = false; // Public properties for bridge identification username; bridgeName; // --- Sub-modules --- commissioningManager; fabricManager; serverLifecycle; stateManager; accessoryManager; accessoryQuery; constructor(config) { super(); // Store the validated config this.config = validateAndSanitizeConfig(config); // Initialize bridge identification properties const cleanId = this.config.uniqueId.replace(NON_HEX_RE, ''); this.username = cleanId.match(HEX_PAIR_RE)?.slice(0, 6).join(':').toUpperCase() || this.config.uniqueId; this.bridgeName = this.config.serialNumber ? `Matter Bridge ${this.config.serialNumber}` : 'Matter Bridge'; // Configure Matter.js library logging if (this.config.debugModeEnabled) { log.info('Matter debug mode enabled - verbose logging active'); MatterLogger.level = MatterLogLevel.DEBUG; } else { MatterLogger.level = MatterLogLevel.NOTICE; } MatterLogger.format = createHomebridgeLogFormatter(); MatterLogger.destinations.default.write = (text) => { if (text.trim() !== '') { console.log(text); // eslint-disable-line no-console } }; // Initialize sub-modules this.commissioningManager = new CommissioningManager(); this.fabricManager = new FabricManager(() => this.serverNode, () => this.serverLifecycle.matterStoragePath); this.serverLifecycle = new ServerLifecycle(); this.behaviorRegistry = new BehaviorRegistry(this.accessories, this); this.registryManager = new RegistryManager(); this.stateManager = new StateManager(this.accessories, this, () => this.monitoringEnabled); this.accessoryManager = new AccessoryManager(); this.accessoryQuery = new AccessoryQuery(this.accessories, () => this.accessoryCache); } // ============================================================================ // Lifecycle methods // ============================================================================ async start() { return this.serverLifecycle.start(this.getLifecycleDeps()); } async runServer() { return this.serverLifecycle.runServer(this.getLifecycleDeps()); } async stop() { return this.serverLifecycle.stop(this.getLifecycleDeps(), this.accessories); } // ============================================================================ // Accessory registration (Plugin API - matches HAP pattern) // ============================================================================ /** * When the last registerPlatformAccessories() call arrived. The * deferred-online settle loop (ChildBridgeMatterManager) polls this to * detect when the initial registration burst has gone idle. */ getLastRegistrationAt() { return this.lastRegistrationAt; } /** Registration calls that have started but not finished; used by the deferred-online settle check. */ getRegistrationsInFlight() { return this.registrationsInFlight; } async registerPlatformAccessories(pluginIdentifier, platformName, accessories) { // Stamp on both entry and exit and track in-flight calls so the deferred- // online settle check never treats a batch that is still registering (or // one that takes longer than the settle window) as idle. this.registrationsInFlight++; this.lastRegistrationAt = Date.now(); try { for (const accessory of accessories) { await this.accessoryManager.registerAccessory(pluginIdentifier, platformName, accessory, this.getAccessoryManagerDeps()); } } finally { this.lastRegistrationAt = Date.now(); this.registrationsInFlight--; } } async unregisterAccessory(uuid) { return this.accessoryManager.unregisterAccessory(uuid, this.getAccessoryManagerDeps()); } async unregisterPlatformAccessories(_pluginIdentifier, _platformName, accessories) { for (const accessory of accessories) { await this.accessoryManager.unregisterAccessory(accessory.UUID, this.getAccessoryManagerDeps()); } } async updatePlatformAccessories(accessories) { if (!this.accessoryCache) { log.warn('Cannot update Matter platform accessories - cache not initialized'); return; } for (const accessory of accessories) { const internal = accessory; if (!this.accessories.has(accessory.UUID)) { log.warn(`Cannot update Matter accessory ${accessory.UUID} - not registered in current session`); continue; } if (!this.accessoryCache.hasCached(accessory.UUID)) { log.warn(`Cannot update Matter accessory ${accessory.UUID} - not found in cache`); continue; } // A plugin passes back the plain accessory object it holds - metadata and // context only. The registered copy additionally carries the runtime state // built during registration (the matter.js endpoint, the part endpoints, // the event emitter). Overwriting the map entry with the plugin's object // therefore threw all of that away, and every later updateAccessoryState // for that accessory failed for good with "not registered or missing // endpoint" (homebridge-plugins/homebridge-noip#190). Merge instead, so // the plugin updates what it owns and the runtime state survives. const existing = this.accessories.get(accessory.UUID); this.accessories.set(accessory.UUID, { ...existing, ...internal, endpoint: existing.endpoint, _parts: existing._parts, _eventEmitter: existing._eventEmitter, registered: existing.registered, _restoredFromCache: existing._restoredFromCache, }); log.debug(`Updated Matter accessory ${accessory.UUID} (${accessory.displayName})`); } this.accessoryCache.requestSave(this.accessories); } // ============================================================================ // State management (Plugin API) // ============================================================================ async updateAccessoryState(uuid, cluster, attributes, partId) { return this.stateManager.updateAccessoryState(uuid, cluster, attributes, partId); } getAccessoryState(uuid, cluster, partId) { return this.stateManager.getAccessoryState(uuid, cluster, partId); } async triggerCommand(uuid, cluster, command, args, partId) { return this.stateManager.triggerCommand(uuid, cluster, command, args, partId); } // ============================================================================ // Accessory queries // ============================================================================ getAccessories() { return this.accessoryQuery.getAccessories(); } getAccessory(uuid) { return this.accessoryQuery.getAccessory(uuid); } getAllCachedAccessories() { return this.accessoryQuery.getAllCachedAccessories(); } getCachedAccessory(uuid) { return this.accessoryQuery.getCachedAccessory(uuid); } collectAccessories(bridgeUsername, bridgeType, bridgeName) { return this.accessoryQuery.collectAccessories(bridgeUsername, bridgeType, bridgeName); } getAccessoryInfo(uuid) { return this.accessoryQuery.getAccessoryInfo(uuid); } // ============================================================================ // Fabric management // ============================================================================ getFabricInfo() { return this.fabricManager.getFabricInfo(); } isCommissioned() { return this.fabricManager.isCommissioned(); } getCommissionedFabricCount() { return this.fabricManager.getCommissionedFabricCount(); } getCommissioningSnapshot() { return this.fabricManager.getCommissioningSnapshot(); } async removeFabric(fabricIndex) { return this.fabricManager.removeFabric(fabricIndex); } hasFabric(fabricIndex) { return this.fabricManager.hasFabric(fabricIndex); } // ============================================================================ // Commissioning info // ============================================================================ getCommissioningInfo() { return { ...this.commissioningManager.commissioningInfo, serialNumber: this.config.serialNumber || this.config.uniqueId, passcode: this.commissioningManager.passcode, discriminator: this.commissioningManager.discriminator, commissioned: this.isCommissioned(), }; } // ============================================================================ // Server info & monitoring // ============================================================================ getServerInfo() { return { running: this.isRunning, port: this.config.port || 5540, deviceCount: this.accessories.size, commissioned: this.isCommissioned(), fabricCount: this.getCommissionedFabricCount(), serialNumber: this.config.serialNumber || this.config.uniqueId, }; } getStorageStats() { // Storage is now managed natively by matter.js return null; } isServerRunning() { return this.isRunning; } /** * True while the node is built but deliberately kept offline in deferOnline * mode, waiting for the initial registration burst to settle. Registrations * are expected in this window, so callers must not treat it as "starting". * The aggregator check keeps this false before start() has built the node * (and when start() failed) - registrations in that gap must be rejected as * "starting", or they would be silently dropped (#3970). */ isDeferredPreOnline() { return this.config.deferOnline === true && !this.isRunning && this.aggregator !== null; } getDeviceTypes() { return deviceTypes; } getClusters() { return clusters; } enableStateMonitoring() { this.monitoringEnabled = true; log.debug('Matter state monitoring enabled'); } disableStateMonitoring() { this.monitoringEnabled = false; log.debug('Matter state monitoring disabled'); } isMonitoringEnabled() { return this.monitoringEnabled; } notifyStateChange(uuid, cluster, state, partId) { this.stateManager.notifyStateChange(uuid, cluster, state, partId); } // ============================================================================ // Internal dependency builders for sub-modules // ============================================================================ getCommissioningDeps() { return { config: this.config, serverNode: this.serverNode, matterStoragePath: this.serverLifecycle.matterStoragePath, serialNumber: this.config.serialNumber || this.config.uniqueId, emitter: this, fabricManager: this.fabricManager, }; } getLifecycleDeps() { return { config: this.config, commissioningManager: this.commissioningManager, fabricManager: this.fabricManager, getCommissioningDeps: () => this.getCommissioningDeps(), getAccessoryCache: () => this.accessoryCache, setAccessoryCache: (cache) => { this.accessoryCache = cache; }, restoreAccessoriesFromCache: async () => { if (!this.accessoryCache) { return; } let cached; try { cached = await this.accessoryCache.load(); } catch (error) { log.warn('Failed to load Matter accessory cache for pre-online restore:', error); return; } if (cached.size === 0) { return; } // Device types are cached as name-only info; resolve them back through // the public registry. const typeByName = new Map(); for (const [key, value] of Object.entries(deviceTypes)) { typeByName.set(key, value); const name = value.name; if (name) { typeByName.set(name, value); } } // Handlers are functions and cannot be cached, but custom behaviors // (e.g. the OnOff server on switch device types) are only attached for // clusters that have handlers. Synthesize empty stubs per cached // cluster so the restored endpoint is structurally identical to the // original; the plugin's re-registration supplies the real handlers. const stubHandlers = (clusters) => { const handlers = {}; for (const clusterName of Object.keys(clusters ?? {})) { handlers[clusterName] = {}; } return Object.keys(handlers).length > 0 ? handlers : undefined; }; let restored = 0; for (const serialized of cached.values()) { try { const deviceType = typeByName.get(serialized.deviceType?.name ?? ''); if (!deviceType) { log.warn(`Cannot restore cached Matter accessory ${serialized.displayName}: unknown device type "${serialized.deviceType?.name}"`); continue; } const parts = (serialized.parts ?? []) .map(part => ({ id: part.id, displayName: part.displayName, deviceType: typeByName.get(part.deviceType?.name ?? ''), clusters: part.clusters ?? {}, handlers: stubHandlers(part.clusters), })) .filter((part) => part.deviceType !== undefined); const accessory = { UUID: serialized.uuid, displayName: serialized.displayName, deviceType, serialNumber: serialized.serialNumber, manufacturer: serialized.manufacturer, model: serialized.model, firmwareRevision: serialized.firmwareRevision, hardwareRevision: serialized.hardwareRevision, softwareVersion: serialized.softwareVersion, context: serialized.context ?? {}, clusters: serialized.clusters ?? {}, handlers: stubHandlers(serialized.clusters), registered: false, _restoredFromCache: true, }; if (parts.length > 0) { accessory.parts = parts; } await this.accessoryManager.registerAccessory(serialized.plugin, serialized.platform, accessory, this.getAccessoryManagerDeps()); restored++; } catch (error) { log.warn(`Failed to restore cached Matter accessory ${serialized.displayName}:`, error); } } if (restored > 0) { log.info(`Restored ${restored} cached Matter accessories into the bridge before going online`); } }, setServerNode: (node) => { this.serverNode = node; }, getServerNode: () => this.serverNode, setAggregator: (agg) => { this.aggregator = agg; }, getAggregator: () => this.aggregator, setIsRunning: (running) => { this.isRunning = running; }, getIsRunning: () => this.isRunning, cleanupHandlers: this.cleanupHandlers, getShutdownHandler: () => this.shutdownHandler, setShutdownHandler: (handler) => { this.shutdownHandler = handler; }, onStop: () => this.stop(), }; } getAccessoryManagerDeps() { return { config: this.config, accessories: this.accessories, behaviorRegistry: this.behaviorRegistry, registryManager: this.registryManager, accessoryCache: this.accessoryCache, getServerNode: () => this.serverNode, getAggregator: () => this.aggregator, getIsRunning: () => this.isRunning, getMonitoringEnabled: () => this.monitoringEnabled, isCommissioned: () => this.isCommissioned(), }; } } //# sourceMappingURL=server.js.map