homebridge
Version:
HomeKit support for the impatient
606 lines • 25.2 kB
JavaScript
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