UNPKG

homebridge

Version:
475 lines 26 kB
/** * Matter Bridge Manager * * Manages Matter server lifecycle and accessories for the main Homebridge bridge. * This class extracts Matter-specific logic from server.ts to minimize changes to core files. */ import { DEFAULT_BRIDGE_DEFAULTS } from '../bridgeService.js'; import { Logger } from '../logger.js'; import { User } from '../user.js'; import getVersion from '../version.js'; import { BaseMatterManager } from './BaseMatterManager.js'; import { publishExternalMatterAccessory } from './ExternalMatterAccessoryPublisher.js'; import { MatterServer } from './server.js'; import { getErrorCode, getMatterJsVersion, normalizeBindConfig } from './utils.js'; const log = Logger.withPrefix('Matter/MainManager'); const COLON_RE = /:/g; /** * Manages Matter server and accessories for the main bridge */ export class MatterBridgeManager extends BaseMatterManager { config; api; externalPortService; options; server; constructor(config, api, externalPortService, pluginManager, options, server) { super(pluginManager); this.config = config; this.api = api; this.externalPortService = externalPortService; this.options = options; this.server = server; this.setupEventListeners(); } // Stored listener references so they can be removed in teardown() _onPublishExternalMatterAccessories = (accessories, registrationId) => { this.handlePublishExternalAccessories(accessories, registrationId).catch((error) => { log.error('Failed to publish external Matter accessories:', error); this.api._resolveExternalRegistration(registrationId); }); }; _onRegisterMatterPlatformAccessories = (pluginIdentifier, platformName, accessories) => { this.handleRegisterPlatformAccessories(pluginIdentifier, platformName, accessories).catch((error) => { log.error(`Failed to register Matter accessories for ${pluginIdentifier}:`, error); }); }; _onUpdateMatterPlatformAccessories = (accessories) => { this.handleUpdatePlatformAccessories(accessories).catch((error) => { log.error('Failed to update Matter platform accessories:', error); }); }; _onUnregisterMatterPlatformAccessories = (pluginIdentifier, platformName, accessories) => { this.handleUnregisterPlatformAccessories(pluginIdentifier, platformName, accessories).catch((error) => { log.error(`Failed to unregister Matter accessories for ${pluginIdentifier}:`, error); }); }; _onUnregisterExternalMatterAccessories = (accessories) => { this.handleUnregisterExternalAccessories(accessories).catch((error) => { log.error('Failed to unregister external Matter accessories:', error); }); }; _onUpdateMatterAccessoryState = (uuid, cluster, attributes, partId) => { this.handleUpdateAccessoryState(uuid, cluster, attributes, partId).catch((error) => { log.error('Failed to update Matter accessory state:', error); }); }; // Stored reference so the stateChange listener can be removed in teardown() _onMatterServerStateChange = ({ uuid, cluster, state, partId }) => { const event = { type: 'accessoryUpdate', data: { uuid, cluster, state, partId }, }; this.server.ipcService.sendMessage("matterEvent" /* IpcOutgoingEvent.MATTER_EVENT */, event); }; /** * Set up event listeners for Matter accessory operations * Subscribes directly to API events instead of requiring server.ts to delegate */ setupEventListeners() { this.api.on("publishExternalMatterAccessories" /* InternalAPIEvent.PUBLISH_EXTERNAL_MATTER_ACCESSORIES */, this._onPublishExternalMatterAccessories); this.api.on("registerMatterPlatformAccessories" /* InternalAPIEvent.REGISTER_MATTER_PLATFORM_ACCESSORIES */, this._onRegisterMatterPlatformAccessories); this.api.on("updateMatterPlatformAccessories" /* InternalAPIEvent.UPDATE_MATTER_PLATFORM_ACCESSORIES */, this._onUpdateMatterPlatformAccessories); this.api.on("unregisterMatterPlatformAccessories" /* InternalAPIEvent.UNREGISTER_MATTER_PLATFORM_ACCESSORIES */, this._onUnregisterMatterPlatformAccessories); this.api.on("unregisterExternalMatterAccessories" /* InternalAPIEvent.UNREGISTER_EXTERNAL_MATTER_ACCESSORIES */, this._onUnregisterExternalMatterAccessories); this.api.on("updateMatterAccessoryState" /* InternalAPIEvent.UPDATE_MATTER_ACCESSORY_STATE */, this._onUpdateMatterAccessoryState); } /** * Initialize Matter server for main bridge if enabled */ async initialize() { // Check if main bridge has matter configuration if (!this.config.bridge.matter) { return; } // Declare matterPort outside try block so it's accessible in catch let matterPort; try { log.info('Initializing Matter server for main bridge...'); // Allocate port from pool if not explicitly configured matterPort = this.config.bridge.matter.port; if (!matterPort) { matterPort = await this.externalPortService.requestPort(`${this.config.bridge.username}:MATTER`); if (!matterPort) { matterPort = 5540; // Default Matter port log.warn('No port available from pool for main Matter bridge, using default port 5540'); } else { log.info(`Allocated port ${matterPort} from pool for main Matter bridge`); } } // Create Matter server instance with config inheritance from main bridge const serialNumber = this.config.bridge.username.replace(COLON_RE, ''); // Normalize bind config to array format const networkInterfaces = normalizeBindConfig(this.config.bridge.bind); this.matterServer = new MatterServer({ storagePath: User.matterPath(), port: matterPort, uniqueId: serialNumber, displayName: this.config.bridge.name || 'Main Bridge', manufacturer: this.config.bridge.manufacturer || DEFAULT_BRIDGE_DEFAULTS.manufacturer, model: this.config.bridge.model || DEFAULT_BRIDGE_DEFAULTS.model, firmwareRevision: this.config.bridge.firmwareRevision || getVersion(), serialNumber, debugModeEnabled: this.options.debugModeEnabled, networkInterfaces, }); // Start the Matter server await this.matterServer.start(); // Log Homebridge and Matter.js version info, matching child bridge log style const matterJsVersion = await getMatterJsVersion(); log.success('Homebridge v%s (Matter.js v%s) (%s) is running on port %s.', getVersion(), matterJsVersion, this.config.bridge.name, matterPort); log.info('Matter server initialized for main bridge'); // Inform the API that Matter is enabled this.api._setMatterEnabled(true); // Set the Matter server reference for API methods like getAccessoryState this.api._setMatterServer(this.matterServer); // Listen for state changes and forward to UI via IPC this.matterServer.on('stateChange', this._onMatterServerStateChange); } catch (error) { log.error('Failed to initialize Matter server for main bridge:', error); // Provide user-friendly guidance for common errors const errorMessage = error instanceof Error ? error.message : String(error); const errorCode = getErrorCode(error); if (errorMessage.includes('corrupted')) { log.error(''); log.error('╔════════════════════════════════════════════════════════════════════════════╗'); log.error('║ MATTER STORAGE CORRUPTED ║'); log.error('╠════════════════════════════════════════════════════════════════════════════╣'); log.error('║ Your Matter storage has become corrupted. This can happen when: ║'); log.error('║ • Matter.js library version changes ║'); log.error('║ • Storage format upgrades occur ║'); log.error('║ • Incomplete writes during shutdown ║'); log.error('║ ║'); log.error('║ To fix this, delete the corrupted storage directory: ║'); log.error(`║ rm -rf ~/.homebridge/matter/${this.config.bridge.username} ║`); log.error('║ ║'); log.error('║ Note: You will need to re-pair your Matter devices after deletion. ║'); log.error('╚════════════════════════════════════════════════════════════════════════════╝'); log.error(''); } else if (errorCode === 'EADDRINUSE' || errorMessage.includes('address already in use')) { log.error(''); log.error('╔════════════════════════════════════════════════════════════════════════════╗'); log.error('║ MATTER PORT ALREADY IN USE ║'); log.error('╠════════════════════════════════════════════════════════════════════════════╣'); log.error(`║ Port ${matterPort} is already in use by another application. ║`); log.error('║ ║'); log.error('║ To fix this: ║'); log.error('║ 1. Stop the application using this port, or ║'); log.error('║ 2. Configure a different port in your config.json: ║'); log.error('║ "bridge": { ║'); log.error('║ "matter": { ║'); log.error('║ "port": <different-port> ║'); log.error('║ } ║'); log.error('║ } ║'); log.error('╚════════════════════════════════════════════════════════════════════════════╝'); log.error(''); } } } /** * Handle external Matter accessories - each gets its own dedicated Matter server * This is required for devices like Robotic Vacuum Cleaners that Apple Home * requires to be on their own bridge. */ async handlePublishExternalAccessories(accessories, registrationId) { log.info(`Publishing ${accessories.length} external Matter accessor${accessories.length === 1 ? 'y' : 'ies'}`); // Normalize bind config to array format (inherit from main bridge) const networkInterfaces = normalizeBindConfig(this.config.bridge.bind); try { for (const accessory of accessories) { try { // Check if already published if (this.externalMatterServers.has(accessory.UUID)) { log.warn(`External Matter accessory ${accessory.displayName} (${accessory.UUID}) is already published`); continue; } // Publish the accessory using shared helper const result = await publishExternalMatterAccessory(accessory, { portService: this.externalPortService, networkInterfaces, debugModeEnabled: this.options.debugModeEnabled, }); if (!result) { // Validation or publishing failed (errors already logged by helper) continue; } // Store the server instance this.externalMatterServers.set(accessory.UUID, result.server); // Listen for state changes and forward to UI via IPC // (same pattern as the main bridge server listener in initialize()) result.server.on('stateChange', this._onMatterServerStateChange); // Register the external bridge username for direct routing // Use main bridge's username for consistent lookups this.server.registerExternalMatterBridge(result.username, this.config.bridge.username); // Emit the 'ready' event to notify plugins that the accessory is now available on the network // This is similar to HAP's 'advertised' event and signals that the Matter server is running // and the accessory can be commissioned by Matter controllers if (accessory._eventEmitter) { accessory._eventEmitter.emit('ready', result.port); } // Log commissioning info if (result.commissioningInfo.qrCode && result.commissioningInfo.manualPairingCode) { log.info(`📱 Commissioning codes for ${accessory.displayName}:`); log.info(` QR Code: ${result.commissioningInfo.qrCode}`); log.info(` Manual Code: ${result.commissioningInfo.manualPairingCode}`); } } catch (error) { log.error(`Failed to publish external Matter accessory ${accessory.displayName}:`, error); } } } finally { // Notify that registration is complete (whether successful or not) this.api._resolveExternalRegistration(registrationId); } } /** * Get Matter server status information for IPC communication */ getMatterStatus() { // Include Matter commissioning info if Matter is enabled if (this.matterServer) { const commissioningInfo = this.matterServer.getCommissioningInfo(); return { enabled: true, port: this.config.bridge.matter?.port, setupUri: commissioningInfo.qrCode, pin: commissioningInfo.manualPairingCode, serialNumber: commissioningInfo.serialNumber, commissioned: commissioningInfo.commissioned || false, deviceCount: this.matterServer.getAccessories().length, }; } else if (this.config.bridge.matter) { // Matter is configured but not yet started (or failed to start) return { enabled: false, port: this.config.bridge.matter?.port, }; } return { enabled: false, }; } /** * Collect all Matter accessories from all sources * * @param bridgeUsername - Optional: specific bridge username to filter by * @returns Array of accessory data suitable for UI consumption */ collectAllAccessories(bridgeUsername) { const accessories = []; // Main bridge accessories (if no specific bridge requested or requesting main bridge) if (!bridgeUsername || bridgeUsername === this.config.bridge.username) { if (this.matterServer) { const mainAccessories = this.collectAccessoriesFromServer(this.matterServer, this.config.bridge.username, 'main', this.config.bridge.name || 'Homebridge'); accessories.push(...mainAccessories); // External accessories (belong to main bridge context) for (const server of this.externalMatterServers.values()) { const externalAccessories = this.collectAccessoriesFromServer(server, server.username, 'external', server.bridgeName); accessories.push(...externalAccessories); } } } return accessories; } /** * Get detailed info for a specific Matter accessory * * @param uuid - Accessory UUID * @returns Accessory info or undefined if not found */ getAccessoryInfo(uuid) { // Search main bridge if (this.matterServer) { const accessory = this.getAccessoryDetailFromServer(this.matterServer, uuid, this.config.bridge.username, 'main'); if (accessory) { return accessory; } } // Search external servers for (const server of this.externalMatterServers.values()) { const accessory = this.getAccessoryDetailFromServer(server, uuid, server.username, 'external'); if (accessory) { return accessory; } } return undefined; } /** * Collect accessories from a specific Matter server * * @param server - Matter server instance * @param bridgeUsername - Bridge MAC address * @param bridgeType - Type of bridge (main/child/external) * @param bridgeName - Display name of the bridge * @returns Array of accessory information */ collectAccessoriesFromServer(server, bridgeUsername, bridgeType, bridgeName) { const cached = server.getAllCachedAccessories(); const accessories = []; // Fabric/commissioning state is server-wide — read it once, then share // the snapshot across every accessory transform instead of re-reading it // (3 fabric calls deep) per cached accessory. const snapshot = server.getCommissioningSnapshot(); for (const acc of cached) { const accessory = this.transformAccessoryData(acc, server, bridgeUsername, bridgeType, bridgeName, snapshot); accessories.push(accessory); } return accessories; } /** * Transform accessory data for UI consumption * * @param acc - Cached accessory data * @param server - Matter server instance * @param bridgeUsername - Bridge MAC address * @param bridgeType - Type of bridge * @param bridgeName - Display name of the bridge * @returns Transformed accessory info for UI */ transformAccessoryData(acc, server, bridgeUsername, bridgeType, bridgeName, snapshot) { // Get current state const currentState = this.getCurrentStateFromServer(server, acc.uuid); // Convert device type object to string representation const deviceTypeStr = acc.deviceType.name || `Device Code ${acc.deviceType.code || 'unknown'}`; return { // Identity uuid: acc.uuid, displayName: acc.displayName, serialNumber: acc.serialNumber, manufacturer: acc.manufacturer, model: acc.model, firmwareRevision: acc.firmwareRevision, // Device type deviceType: deviceTypeStr, // Current cluster states clusters: currentState, // Parts (composed devices) parts: acc.parts?.map(part => ({ id: part.id, displayName: part.displayName, deviceType: part.deviceType.name || `Device Code ${part.deviceType.code || 'unknown'}`, clusters: this.getCurrentStateFromServer(server, acc.uuid, part.id), })), // Bridge info bridge: { username: bridgeUsername, type: bridgeType, name: bridgeName, }, // Plugin info plugin: acc.plugin, platform: acc.platform, // Context (plugin-specific data) context: acc.context, // Commissioning info (if available) — sourced from a single snapshot // built once per server in the caller, not per-accessory. commissioned: snapshot.commissioned, fabricCount: snapshot.fabricCount, // Map fabric info from Matter.js format to our interface fabrics: snapshot.fabrics.map(fabric => ({ fabricIndex: fabric.fabricIndex, fabricId: BigInt(fabric.fabricId), nodeId: BigInt(fabric.nodeId), vendorId: fabric.rootVendorId, // Matter.js uses rootVendorId label: fabric.label, })), }; } /** * Get detailed accessory info from a specific server * * @param server - Matter server instance * @param uuid - Accessory UUID * @param bridgeUsername - Bridge MAC address * @param bridgeType - Type of bridge * @returns Accessory info or undefined if not found */ getAccessoryDetailFromServer(server, uuid, bridgeUsername, bridgeType) { const accessory = server.getAccessory(uuid); if (!accessory) { return undefined; } const cached = server.getCachedAccessory(uuid); if (!cached) { return undefined; } return this.transformAccessoryData(cached, server, bridgeUsername, bridgeType, server.bridgeName || 'Matter Bridge', server.getCommissioningSnapshot()); } /** * Get current state from Matter server for an accessory */ getCurrentStateFromServer(server, uuid, partId) { const accessory = server.getAccessory(uuid); if (!accessory) { return {}; } const endpoint = partId ? accessory._parts?.find((p) => p.id === partId)?.endpoint : accessory.endpoint; if (!endpoint) { return {}; } const state = {}; for (const [clusterName, clusterState] of Object.entries(endpoint.state)) { state[clusterName] = {}; for (const [key, value] of Object.entries(clusterState)) { if (!key.startsWith('_') && !key.startsWith('$')) { state[clusterName][key] = value; } } } return state; } /** * Teardown Matter servers */ async teardown() { // Remove API event listeners to prevent retention of this manager after teardown this.api.removeListener("publishExternalMatterAccessories" /* InternalAPIEvent.PUBLISH_EXTERNAL_MATTER_ACCESSORIES */, this._onPublishExternalMatterAccessories); this.api.removeListener("registerMatterPlatformAccessories" /* InternalAPIEvent.REGISTER_MATTER_PLATFORM_ACCESSORIES */, this._onRegisterMatterPlatformAccessories); this.api.removeListener("updateMatterPlatformAccessories" /* InternalAPIEvent.UPDATE_MATTER_PLATFORM_ACCESSORIES */, this._onUpdateMatterPlatformAccessories); this.api.removeListener("unregisterMatterPlatformAccessories" /* InternalAPIEvent.UNREGISTER_MATTER_PLATFORM_ACCESSORIES */, this._onUnregisterMatterPlatformAccessories); this.api.removeListener("unregisterExternalMatterAccessories" /* InternalAPIEvent.UNREGISTER_EXTERNAL_MATTER_ACCESSORIES */, this._onUnregisterExternalMatterAccessories); this.api.removeListener("updateMatterAccessoryState" /* InternalAPIEvent.UPDATE_MATTER_ACCESSORY_STATE */, this._onUpdateMatterAccessoryState); // Stop main Matter server if running if (this.matterServer) { try { this.matterServer.removeListener('stateChange', this._onMatterServerStateChange); await this.matterServer.stop(); } catch (error) { log.error('Failed to stop Matter server:', error); } } // Stop all external Matter servers for (const [uuid, matterServer] of this.externalMatterServers) { try { matterServer.removeListener('stateChange', this._onMatterServerStateChange); await matterServer.stop(); log.debug(`Stopped external Matter server for ${uuid}`); } catch (error) { log.error(`Failed to stop external Matter server for ${uuid}:`, error); } } this.externalMatterServers.clear(); // Child bridge Matter servers are stopped by their own forked processes } } //# sourceMappingURL=MatterBridgeManager.js.map