UNPKG

homebridge

Version:
606 lines 25.2 kB
import { fork } from 'node:child_process'; import { dirname, resolve } from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; import fs from 'fs-extra'; import { Logger } from './logger.js'; import { User } from './user.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const COLON_RE = /:/g; // eslint-disable-next-line no-restricted-syntax export var ChildProcessMessageEventType; (function (ChildProcessMessageEventType) { /** * Sent from the child process when it is ready to accept config */ ChildProcessMessageEventType["READY"] = "ready"; /** * Sent to the child process with a ChildProcessLoadEventData payload */ ChildProcessMessageEventType["LOAD"] = "load"; /** * Sent from the child process once it has loaded the plugin */ ChildProcessMessageEventType["LOADED"] = "loaded"; /** * Sent to the child process telling it to start */ ChildProcessMessageEventType["START"] = "start"; /** * Sent from the child process when the bridge is online */ ChildProcessMessageEventType["ONLINE"] = "online"; /** * Sent from the child when it wants to request port allocation for an external accessory */ ChildProcessMessageEventType["PORT_REQUEST"] = "portRequest"; /** * Sent from the parent with the port allocation response */ ChildProcessMessageEventType["PORT_ALLOCATED"] = "portAllocated"; /** * Sent from the child to update its current status */ ChildProcessMessageEventType["STATUS_UPDATE"] = "status"; /** * Sent to the child to start Matter monitoring */ ChildProcessMessageEventType["START_MATTER_MONITORING"] = "startMatterMonitoring"; /** * Sent to the child to stop Matter monitoring */ ChildProcessMessageEventType["STOP_MATTER_MONITORING"] = "stopMatterMonitoring"; /** * Sent to the child to get Matter accessories */ ChildProcessMessageEventType["GET_MATTER_ACCESSORIES"] = "getMatterAccessories"; /** * Sent to the child to get specific Matter accessory info */ ChildProcessMessageEventType["GET_MATTER_ACCESSORY_INFO"] = "getMatterAccessoryInfo"; /** * Sent to the child to control a Matter accessory */ ChildProcessMessageEventType["MATTER_ACCESSORY_CONTROL"] = "matterAccessoryControl"; /** * Unified Matter event from child process * Includes: accessoriesData, accessoryInfoData, accessoryControlResponse, * accessoryUpdate, accessoryAdded, accessoryRemoved */ ChildProcessMessageEventType["MATTER_EVENT"] = "matterEvent"; })(ChildProcessMessageEventType || (ChildProcessMessageEventType = {})); // eslint-disable-next-line no-restricted-syntax export var ChildBridgeStatus; (function (ChildBridgeStatus) { /** * When the child bridge is loading, or restarting */ ChildBridgeStatus["PENDING"] = "pending"; /** * The child bridge is online and has published it's accessory */ ChildBridgeStatus["OK"] = "ok"; /** * The bridge is shutting down, or the process ended unexpectedly */ ChildBridgeStatus["DOWN"] = "down"; })(ChildBridgeStatus || (ChildBridgeStatus = {})); /** * Manages the child processes of platforms/accessories being exposed as separate forked bridges. * A child bridge runs a single platform or accessory. */ export class ChildBridgeService { type; identifier; plugin; bridgeConfig; homebridgeConfig; homebridgeOptions; api; ipcService; externalPortService; child; args = []; processEnv = {}; shuttingDown = false; lastBridgeStatus = "pending" /* ChildBridgeStatus.PENDING */; pairedStatus = null; manuallyStopped = false; setupUri = null; matterCommissioningInfo; pluginConfig = []; log; displayName; restartCount = 0; maxRestarts = 4; scheduledRestartTimeout; // Matter accessories pending response callback matterAccessoriesResolve; // Callback for external Matter bridge registration onExternalBridgeRegistered; // Stored shutdown listener so it can be removed in teardown(), // matching the pattern used by MatterBridgeManager (#3915). _onApiShutdown = () => { this.shuttingDown = true; this.teardown(); }; constructor(type, identifier, plugin, bridgeConfig, homebridgeConfig, homebridgeOptions, api, ipcService, externalPortService) { this.type = type; this.identifier = identifier; this.plugin = plugin; this.bridgeConfig = bridgeConfig; this.homebridgeConfig = homebridgeConfig; this.homebridgeOptions = homebridgeOptions; this.api = api; this.ipcService = ipcService; this.externalPortService = externalPortService; this.log = Logger.withPrefix(this.plugin.getPluginIdentifier()); this.api.on('shutdown', this._onApiShutdown); // make sure we don't hit the max listeners limit this.api.setMaxListeners(this.api.getMaxListeners() + 1); } /** * Start the child bridge service */ start() { this.setProcessFlags(); this.setProcessEnv(); this.startChildProcess(); // set display name if (this.pluginConfig.length !== 1) { this.displayName = this.plugin.getPluginIdentifier(); } else { this.displayName = this.pluginConfig[0]?.name || this.plugin.getPluginIdentifier(); } // re-configured log with display name this.log = Logger.withPrefix(this.displayName); } /** * Add a config block to a child bridge. * Platform child bridges can only contain one config block. * @param config */ addConfig(config) { this.pluginConfig.push(config); } /** * Start Matter monitoring on this child bridge */ startMatterMonitoring() { this.sendMessage("startMatterMonitoring" /* ChildProcessMessageEventType.START_MATTER_MONITORING */); } /** * Stop Matter monitoring on this child bridge */ stopMatterMonitoring() { this.sendMessage("stopMatterMonitoring" /* ChildProcessMessageEventType.STOP_MATTER_MONITORING */); } /** * Request Matter accessories from this child bridge. * Returns a promise that resolves when the child responds, or undefined on timeout. */ requestMatterAccessories(timeoutMs = 500) { return new Promise((resolve) => { const timeout = setTimeout(() => { this.matterAccessoriesResolve = undefined; resolve(undefined); }, timeoutMs); this.matterAccessoriesResolve = (data) => { clearTimeout(timeout); this.matterAccessoriesResolve = undefined; resolve(data); }; this.sendMessage("getMatterAccessories" /* ChildProcessMessageEventType.GET_MATTER_ACCESSORIES */); }); } /** * Get specific Matter accessory info from this child bridge */ getMatterAccessoryInfo(uuid) { this.sendMessage("getMatterAccessoryInfo" /* ChildProcessMessageEventType.GET_MATTER_ACCESSORY_INFO */, { uuid }); } /** * Control a Matter accessory on this child bridge */ controlMatterAccessory(data) { this.sendMessage("matterAccessoryControl" /* ChildProcessMessageEventType.MATTER_ACCESSORY_CONTROL */, data); } get bridgeStatus() { return this.lastBridgeStatus; } set bridgeStatus(value) { this.lastBridgeStatus = value; this.sendStatusUpdate(); } /** * Start the child bridge process */ startChildProcess() { this.bridgeStatus = "pending" /* ChildBridgeStatus.PENDING */; this.child = fork(resolve(__dirname, 'childBridgeFork.js'), this.args, this.processEnv); this.child.stdout?.on('data', (data) => { process.stdout.write(data); }); this.child.stderr?.on('data', (data) => { process.stderr.write(data); }); this.child.on('error', (e) => { this.bridgeStatus = "down" /* ChildBridgeStatus.DOWN */; this.log.error('Child bridge process error', e); }); this.child.once('close', (code, signal) => { this.handleProcessClose(code, signal); }); // handle incoming ipc messages from the child process this.child.on('message', (message) => { if (typeof message !== 'object' || !message.id) { return; } switch (message.id) { case "ready" /* ChildProcessMessageEventType.READY */: { this.log(`Child bridge starting${this.child?.pid ? ` (pid ${this.child.pid})` : ''}...`); this.loadPlugin(); break; } case "loaded" /* ChildProcessMessageEventType.LOADED */: { const version = message.data.version; if (this.pluginConfig.length > 1) { this.log.success(`Child bridge started successfully with ${this.pluginConfig.length} accessories (plugin v${version}).`); } else { this.log.success(`Child bridge started successfully (plugin v${version}).`); } this.startBridge(); break; } case "online" /* ChildProcessMessageEventType.ONLINE */: { this.bridgeStatus = "ok" /* ChildBridgeStatus.OK */; break; } case "portRequest" /* ChildProcessMessageEventType.PORT_REQUEST */: { void this.handlePortRequest(message.data); break; } case "status" /* ChildProcessMessageEventType.STATUS_UPDATE */: { // Handle unified status update with HAP and Matter info const statusData = message.data; // Update HAP status this.pairedStatus = statusData.paired; this.setupUri = statusData.setupUri; // Update Matter commissioning info if included if (statusData.matter) { this.matterCommissioningInfo = { qrCode: statusData.matter.qrCode, manualPairingCode: statusData.matter.manualPairingCode, serialNumber: statusData.matter.serialNumber, commissioned: statusData.matter.commissioned || false, deviceCount: statusData.matter.deviceCount, }; } // Send unified status update this.sendStatusUpdate(); break; } case "matterEvent" /* ChildProcessMessageEventType.MATTER_EVENT */: { // Handle unified Matter event const matterEvent = message.data; // Special handling for accessoriesData - resolve pending request if (matterEvent.type === 'accessoriesData') { this.matterAccessoriesResolve?.(matterEvent.data); } else if (matterEvent.type === 'externalBridgeRegistration') { // Handle external bridge registration - register directly with callback const data = matterEvent.data; if (this.onExternalBridgeRegistered) { // Pass the child bridge username (not identifier) so it can be looked up in childBridges Map this.onExternalBridgeRegistered(data.externalBridgeUsername, this.bridgeConfig.username); } } else { // Forward all other Matter events to main process IPC this.ipcService.sendMessage("matterEvent" /* IpcOutgoingEvent.MATTER_EVENT */, matterEvent); } break; } } }); } /** * Called when the child bridge process exits, if Homebridge is not shutting down, it will restart the process * @param code * @param signal */ handleProcessClose(code, signal) { const isLikelyPluginCrash = code === 1 && signal === null; this.log.warn(`Child bridge ended (code ${code}, signal ${signal}).${isLikelyPluginCrash ? ' The child bridge ended unexpectedly, which is normally due to the plugin not catching its errors properly. Please report this to the plugin developer by clicking on the' + ' \'Report An Issue\' option in the plugin menu dropdown from the Homebridge UI. If there are related logs shown above, please include them in your report.' : ''}`); if (isLikelyPluginCrash) { if (this.restartCount < this.maxRestarts) { this.bridgeStatus = "pending" /* ChildBridgeStatus.PENDING */; this.restartCount += 1; const delay = this.restartCount * 10; // first attempt after 10 seconds, second after 20 seconds, etc. this.log(`Child bridge will automatically restart in ${delay} seconds (restart attempt ${this.restartCount} of ${this.maxRestarts}).`); this.scheduledRestartTimeout = setTimeout(() => { this.scheduledRestartTimeout = undefined; if (!this.shuttingDown && !this.manuallyStopped) { this.startChildProcess(); } }, delay * 1000); } else { this.bridgeStatus = "down" /* ChildBridgeStatus.DOWN */; this.manuallyStopped = true; this.log.error(`Child bridge will no longer restart after failing ${this.maxRestarts + 1} times, you will need to manually start this child bridge from the Homebridge UI.`); } return; } if (!this.shuttingDown) { this.bridgeStatus = "down" /* ChildBridgeStatus.DOWN */; this.restartCount = 0; this.startChildProcess(); } } /** * Helper function to send a message to the child process * @param type * @param data */ sendMessage(type, data) { if (this.child && this.child.connected) { this.child.send({ id: type, data, }); } } /** * Some plugins may make use of the homebridge process flags * These will be passed through to the forked process */ setProcessFlags() { if (this.bridgeConfig.debugModeEnabled) { this.args.push('-D'); } if (this.homebridgeOptions.forceColourLogging) { this.args.push('-C'); } if (this.homebridgeOptions.insecureAccess) { this.args.push('-I'); } if (this.homebridgeOptions.noLogTimestamps) { this.args.push('-T'); } if (this.homebridgeOptions.keepOrphanedCachedAccessories) { this.args.push('-K'); } if (this.homebridgeOptions.customStoragePath) { this.args.push('-U', this.homebridgeOptions.customStoragePath); } if (this.homebridgeOptions.customPluginPath) { this.args.push('-P', this.homebridgeOptions.customPluginPath); } } /** * Set environment variables for the child process */ setProcessEnv() { this.processEnv = { env: { ...process.env, DEBUG: `${process.env.DEBUG || ''} ${this.bridgeConfig.env?.DEBUG || ''}`.trim(), NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} ${this.bridgeConfig.env?.NODE_OPTIONS || ''}`.trim(), }, silent: true, }; } /** * Tell the child process to load the given plugin */ loadPlugin() { const bridgeConfig = { name: this.bridgeConfig.name || this.displayName || this.plugin.getPluginIdentifier(), port: this.bridgeConfig.port, username: this.bridgeConfig.username, advertiser: this.homebridgeConfig.bridge.advertiser, pin: this.bridgeConfig.pin || this.homebridgeConfig.bridge.pin, bind: this.homebridgeConfig.bridge.bind, setupID: this.bridgeConfig.setupID, manufacturer: this.bridgeConfig.manufacturer || this.homebridgeConfig.bridge.manufacturer, model: this.bridgeConfig.model || this.homebridgeConfig.bridge.model, firmwareRevision: this.bridgeConfig.firmwareRevision || this.homebridgeConfig.bridge.firmwareRevision, serialNumber: this.bridgeConfig.serialNumber || this.bridgeConfig.username, hap: this.bridgeConfig.hap, matter: this.bridgeConfig.matter, }; const bridgeOptions = { cachedAccessoriesDir: User.cachedAccessoryPath(), cachedAccessoriesItemName: `cachedAccessories.${this.bridgeConfig.username.replace(COLON_RE, '').toUpperCase()}`, }; // shallow copy the homebridge options to the bridge options object Object.assign(bridgeOptions, this.homebridgeOptions); // Override with child bridge specific settings if (this.bridgeConfig.debugModeEnabled !== undefined) { bridgeOptions.debugModeEnabled = this.bridgeConfig.debugModeEnabled; } this.sendMessage("load" /* ChildProcessMessageEventType.LOAD */, { type: this.type, identifier: this.identifier, pluginPath: this.plugin.getPluginPath(), pluginConfig: this.pluginConfig, bridgeConfig, bridgeOptions, homebridgeConfig: { bridge: this.homebridgeConfig.bridge, ports: this.homebridgeConfig.ports, disabledPlugins: [], // not used by child bridges accessories: [], // not used by child bridges platforms: [], // not used by child bridges }, }); } /** * Tell the child bridge to start broadcasting */ startBridge() { this.sendMessage("start" /* ChildProcessMessageEventType.START */); } /** * Handle external port requests from child */ async handlePortRequest(request) { let port; if (request.portType === 'matter') { // Request from Matter port pool port = await this.externalPortService.requestMatterPort(request.username); } else { // Request from HAP port pool (default) port = await this.externalPortService.requestPort(request.username); } this.sendMessage("portAllocated" /* ChildProcessMessageEventType.PORT_ALLOCATED */, { username: request.username, port, }); } /** * Send sigterm to the child bridge, escalating to sigkill if the child * does not exit within 10 seconds. */ teardown() { // Remove the api shutdown listener so this service can be GC'd. this.api.removeListener('shutdown', this._onApiShutdown); this.api.setMaxListeners(Math.max(0, this.api.getMaxListeners() - 1)); if (this.child && this.child.connected) { this.bridgeStatus = "down" /* ChildBridgeStatus.DOWN */; const child = this.child; child.kill('SIGTERM'); // If the child has not exited within 10s, escalate to SIGKILL. // The 'close' handler will clear this in the normal-exit path because // child.connected becomes false before close fires. const sigkillTimer = setTimeout(() => { if (child.connected) { this.log.warn('Child bridge did not exit within 10s of SIGTERM; escalating to SIGKILL.'); child.kill('SIGKILL'); } }, 10000); sigkillTimer.unref(); } } /** * Trigger sending child bridge metadata to the process parent via IPC */ sendStatusUpdate() { this.ipcService.sendMessage("childBridgeStatusUpdate" /* IpcOutgoingEvent.CHILD_BRIDGE_STATUS_UPDATE */, this.getMetadata()); } /** * Restarts the child bridge process */ restartChildBridge() { if (this.manuallyStopped) { this.restartCount = 0; this.startChildBridge(); } else { this.log.warn('Child bridge restarting...'); void this.refreshConfig(); this.teardown(); } } /** * Stops the child bridge, not starting it again */ stopChildBridge() { if (!this.shuttingDown) { this.log.warn('Child bridge stopping, will not restart.'); this.shuttingDown = true; this.manuallyStopped = true; this.restartCount = 0; if (this.scheduledRestartTimeout) { clearTimeout(this.scheduledRestartTimeout); this.scheduledRestartTimeout = undefined; } this.bridgeStatus = "down" /* ChildBridgeStatus.DOWN */; this.child?.removeAllListeners(); this.teardown(); } else { this.log.warn('Child bridge already shutting down or stopped.'); } } /** * Starts the child bridge, only if it was manually stopped and is no longer running */ startChildBridge() { if (this.manuallyStopped && this.bridgeStatus === "down" /* ChildBridgeStatus.DOWN */ && (!this.child || !this.child.connected)) { void this.refreshConfig(); this.startChildProcess(); this.shuttingDown = false; this.manuallyStopped = false; } else { this.log.warn('Child bridge cannot be started, it is still running or was not manually stopped.'); } } /** * Read the config.json file from disk and refresh the plugin config block for just this plugin */ async refreshConfig() { try { const homebridgeConfig = await fs.readJson(User.configPath()); if (this.type === "platform" /* PluginType.PLATFORM */) { const config = homebridgeConfig.platforms?.filter(x => x.platform === this.identifier && x._bridge?.username === this.bridgeConfig.username); if (config.length) { this.pluginConfig = config; this.bridgeConfig = this.pluginConfig[0]._bridge || this.bridgeConfig; } else { this.log.warn('Platform config could not be found, using existing config.'); } } else if (this.type === "accessory" /* PluginType.ACCESSORY */) { const config = homebridgeConfig.accessories?.filter(x => x.accessory === this.identifier && x._bridge?.username === this.bridgeConfig.username); if (config.length) { this.pluginConfig = config; this.bridgeConfig = this.pluginConfig[0]._bridge || this.bridgeConfig; } else { this.log.warn('Accessory config could not be found, using existing config.'); } } } catch (error) { this.log.error('Failed to refresh plugin config:', error.message); } } /** * Returns metadata about this child bridge */ getMetadata() { return { status: this.bridgeStatus, paired: this.pairedStatus, setupUri: this.setupUri, username: this.bridgeConfig.username, port: this.bridgeConfig.port, pin: this.bridgeConfig.pin || this.homebridgeConfig.bridge.pin, name: this.bridgeConfig.name || this.displayName || this.plugin.getPluginIdentifier(), plugin: this.plugin.getPluginIdentifier(), identifier: this.identifier, pid: this.child?.pid, manuallyStopped: this.manuallyStopped, hap: this.bridgeConfig.hap, matterConfig: this.bridgeConfig.matter, matterIdentifier: this.bridgeConfig.matter ? this.bridgeConfig.username : undefined, matterSetupUri: this.matterCommissioningInfo?.qrCode, matterPin: this.matterCommissioningInfo?.manualPairingCode, matterSerialNumber: this.matterCommissioningInfo?.serialNumber, matterCommissioned: this.matterCommissioningInfo?.commissioned, matterDeviceCount: this.matterCommissioningInfo?.deviceCount, }; } } //# sourceMappingURL=childBridgeService.js.map