homebridge
Version:
HomeKit support for the impatient
415 lines • 25 kB
JavaScript
import { Accessory, Bridge, Characteristic, HAPLibraryVersion, once, Service, uuid, } from '@homebridge/hap-nodejs';
import { getLogPrefix, Logger } from './logger.js';
import { PlatformAccessory } from './platformAccessory.js';
import { PluginManager } from './pluginManager.js';
import { StorageService } from './storageService.js';
import { generate } from './util/mac.js';
import getVersion from './version.js';
export const DEFAULT_BRIDGE_DEFAULTS = {
vendorName: 'Homebridge',
manufacturer: 'homebridge.io',
model: 'homebridge',
};
const log = Logger.internal;
export class BridgeService {
api;
pluginManager;
externalPortService;
bridgeOptions;
bridgeConfig;
bridge;
storageService;
allowInsecureAccess;
cachedPlatformAccessories = [];
cachedAccessoriesFileLoaded = false;
publishedExternalAccessories = new Map();
constructor(api, pluginManager, externalPortService, bridgeOptions, bridgeConfig) {
this.api = api;
this.pluginManager = pluginManager;
this.externalPortService = externalPortService;
this.bridgeOptions = bridgeOptions;
this.bridgeConfig = bridgeConfig;
this.storageService = new StorageService(this.bridgeOptions.cachedAccessoriesDir);
this.storageService.initSync();
// Server is "secure by default", meaning it creates a top-level Bridge accessory that
// will not allow unauthenticated requests. This matches the behavior of actual HomeKit
// accessories. However, you can set this to true to allow all requests without authentication,
// which can be useful for easy hacking. Note that this will expose all functions of your
// bridged accessories, like changing characteristics (i.e. flipping your lights on and off).
this.allowInsecureAccess = this.bridgeOptions.insecureAccess || false;
this.api.on("registerPlatformAccessories" /* InternalAPIEvent.REGISTER_PLATFORM_ACCESSORIES */, this.handleRegisterPlatformAccessories.bind(this));
this.api.on("updatePlatformAccessories" /* InternalAPIEvent.UPDATE_PLATFORM_ACCESSORIES */, this.handleUpdatePlatformAccessories.bind(this));
this.api.on("unregisterPlatformAccessories" /* InternalAPIEvent.UNREGISTER_PLATFORM_ACCESSORIES */, this.handleUnregisterPlatformAccessories.bind(this));
this.api.on("publishExternalAccessories" /* InternalAPIEvent.PUBLISH_EXTERNAL_ACCESSORIES */, this.handlePublishExternalAccessories.bind(this));
this.bridge = new Bridge(bridgeConfig.name, uuid.generate('HomeBridge'));
this.bridge.on("characteristic-warning" /* AccessoryEventTypes.CHARACTERISTIC_WARNING */, () => {
// We register characteristic warning handlers on every bridged accessory (to have a reference to the plugin).
// For Bridges the warnings will propagate to the main Bridge accessory, thus we need to silence them here.
// Otherwise, those would be printed twice (by us and HAP-NodeJS as it detects no handlers on the bridge).
});
}
// characteristic warning event has additional parameter originatorChain: string[] which is currently unused
static printCharacteristicWriteWarning(plugin, accessory, opts, warning) {
const wikiInfo = 'See https://homebridge.io/w/JtMGR for more info.';
switch (warning.type) {
case "slow-read" /* CharacteristicWarningType.SLOW_READ */:
case "slow-write" /* CharacteristicWarningType.SLOW_WRITE */:
if (!opts.ignoreSlow) {
log.info(getLogPrefix(plugin.getPluginIdentifier()), 'This plugin slows down Homebridge.', warning.message, wikiInfo);
}
break;
case "timeout-read" /* CharacteristicWarningType.TIMEOUT_READ */:
case "timeout-write" /* CharacteristicWarningType.TIMEOUT_WRITE */:
log.error(getLogPrefix(plugin.getPluginIdentifier()), 'This plugin slows down Homebridge.', warning.message, wikiInfo);
break;
case "warn-message" /* CharacteristicWarningType.WARN_MESSAGE */:
log.info(getLogPrefix(plugin.getPluginIdentifier()), `This plugin generated a warning from the characteristic '${warning.characteristic.displayName}':`, `${warning.message}.`, wikiInfo);
break;
case "error-message" /* CharacteristicWarningType.ERROR_MESSAGE */:
log.error(getLogPrefix(plugin.getPluginIdentifier()), `This plugin threw an error from the characteristic '${warning.characteristic.displayName}':`, `${warning.message}.`, wikiInfo);
break;
case "debug-message" /* CharacteristicWarningType.DEBUG_MESSAGE */:
log.debug(getLogPrefix(plugin.getPluginIdentifier()), `Characteristic '${warning.characteristic.displayName}':`, `${warning.message}.`, wikiInfo);
break;
default: // generic message for yet unknown types
log.info(getLogPrefix(plugin.getPluginIdentifier()), `This plugin generated a warning from the characteristic '${warning.characteristic.displayName}':`, `${warning.message}.`, wikiInfo);
break;
}
if (warning.stack) {
log.debug(getLogPrefix(plugin.getPluginIdentifier()), warning.stack);
}
}
publishBridge() {
const bridgeConfig = this.bridgeConfig;
const info = this.bridge.getService(Service.AccessoryInformation);
info.setCharacteristic(Characteristic.Manufacturer, bridgeConfig.manufacturer || DEFAULT_BRIDGE_DEFAULTS.manufacturer);
info.setCharacteristic(Characteristic.Model, bridgeConfig.model || DEFAULT_BRIDGE_DEFAULTS.model);
info.setCharacteristic(Characteristic.SerialNumber, bridgeConfig.serialNumber || bridgeConfig.username);
info.setCharacteristic(Characteristic.FirmwareRevision, bridgeConfig.firmwareRevision || getVersion());
this.bridge.on("listening" /* AccessoryEventTypes.LISTENING */, (port) => {
log.success('Homebridge v%s (HAP v%s) (%s) is running on port %s.', getVersion(), HAPLibraryVersion(), bridgeConfig.name, port);
});
const publishInfo = {
username: bridgeConfig.username,
port: bridgeConfig.port,
pincode: bridgeConfig.pin,
category: 2 /* Categories.BRIDGE */,
bind: bridgeConfig.bind,
addIdentifyingMaterial: true,
advertiser: bridgeConfig.advertiser,
};
if (bridgeConfig.setupID && bridgeConfig.setupID.length === 4) {
publishInfo.setupID = bridgeConfig.setupID;
}
log.debug('Publishing bridge accessory (name: %s, publishInfo: %o).', this.bridge.displayName, BridgeService.strippingPinCode(publishInfo));
void this.bridge.publish(publishInfo, this.allowInsecureAccess);
}
/**
* Attempt to load the cached accessories from disk.
*/
async loadCachedPlatformAccessoriesFromDisk() {
let cachedAccessories = null;
try {
cachedAccessories = await this.storageService.getItem(this.bridgeOptions.cachedAccessoriesItemName);
}
catch (error) {
log.error('Failed to load cached accessories from disk:', error.message);
if (error instanceof SyntaxError) {
// syntax error probably means invalid JSON / corrupted file; try and restore from backup
cachedAccessories = await this.restoreCachedAccessoriesBackup();
}
else {
log.error('Not restoring cached accessories - some accessories may be reset.');
}
}
if (cachedAccessories) {
log.info(`Loaded ${cachedAccessories.length} cached accessories from ${this.bridgeOptions.cachedAccessoriesItemName}.`);
this.cachedPlatformAccessories = cachedAccessories.map((serialized) => {
return PlatformAccessory.deserialize(serialized);
});
if (cachedAccessories.length) {
// create a backup of the cache file
await this.createCachedAccessoriesBackup();
}
}
this.cachedAccessoriesFileLoaded = true;
}
/**
* Return the name of the backup cache file
*/
get backupCacheFileName() {
return `.${this.bridgeOptions.cachedAccessoriesItemName}.bak`;
}
/**
* Create a backup of the cached file
* This is used if we ever have trouble reading the main cache file
*/
async createCachedAccessoriesBackup() {
try {
await this.storageService.copyItem(this.bridgeOptions.cachedAccessoriesItemName, this.backupCacheFileName);
}
catch (error) {
log.warn(`Failed to create a backup of the ${this.bridgeOptions.cachedAccessoriesItemName} cached accessories file:`, error.message);
}
}
/**
* Restore a cached accessories backup
* This is used if the main cache file has a JSON syntax error / is corrupted
*/
async restoreCachedAccessoriesBackup() {
try {
const cachedAccessories = await this.storageService.getItem(this.backupCacheFileName);
if (cachedAccessories && cachedAccessories.length) {
log.warn(`Recovered ${cachedAccessories.length} accessories from ${this.bridgeOptions.cachedAccessoriesItemName} cache backup.`);
}
return cachedAccessories;
}
catch (error) {
return null;
}
}
restoreCachedPlatformAccessories() {
this.cachedPlatformAccessories = this.cachedPlatformAccessories.filter((accessory) => {
let plugin = this.pluginManager.getPlugin(accessory._associatedPlugin);
if (!plugin) { // a little explainer here. This section is basically here to resolve plugin name changes of dynamic platform plugins
try {
// resolve platform accessories by searching for plugins which registered a dynamic platform for the given name
plugin = this.pluginManager.getPluginByActiveDynamicPlatform(accessory._associatedPlatform);
if (plugin) { // if it's undefined the no plugin was found
// could improve on this by calculating the Levenshtein distance to only allow platform ownership changes
// when something like a typo happened. Are there other reasons the name could change?
// And how would we define the threshold?
log.info(`When searching for the associated plugin of the accessory '${accessory.displayName}' `
+ `it seems like the plugin name changed from '${accessory._associatedPlugin}' to '${plugin.getPluginIdentifier()}'. Plugin association is now being transformed!`);
accessory._associatedPlugin = plugin.getPluginIdentifier(); // update the associated plugin to the new one
}
}
catch (error) { // error is thrown if multiple plugins where found for the given platform name
log.info(`Could not find the associated plugin for the accessory '${accessory.displayName}'. `
+ `Tried to find the plugin by the platform name but ${error.message}`);
}
}
const platformPlugins = plugin && plugin.getActiveDynamicPlatform(accessory._associatedPlatform);
if (plugin) {
accessory._associatedHAPAccessory.on("characteristic-warning" /* AccessoryEventTypes.CHARACTERISTIC_WARNING */, BridgeService.printCharacteristicWriteWarning.bind(this, plugin, accessory._associatedHAPAccessory, {}));
}
if (!platformPlugins) {
log.info(`Failed to find plugin to handle accessory ${accessory._associatedHAPAccessory.displayName}`);
if (!this.bridgeOptions.keepOrphanedCachedAccessories) {
log.info(`Removing orphaned accessory ${accessory._associatedHAPAccessory.displayName}`);
return false; // filter it from the list
}
}
else {
// We set a placeholder for FirmwareRevision before configureAccessory is called so the plugin has the opportunity to override it.
accessory.getService(Service.AccessoryInformation)?.setCharacteristic(Characteristic.FirmwareRevision, '0');
platformPlugins.configureAccessory(accessory);
}
try {
this.bridge.addBridgedAccessory(accessory._associatedHAPAccessory);
}
catch (error) {
log.warn(`${accessory._associatedPlugin ? getLogPrefix(accessory._associatedPlugin) : ''} Could not restore cached accessory '${accessory._associatedHAPAccessory.displayName}':`, error.message);
return false; // filter it from the list
}
return true; // keep it in the list
});
}
/**
* Save the cached accessories back to disk.
*/
saveCachedPlatformAccessoriesOnDisk() {
try {
// only save the cache file back to disk if we have already attempted to load it
// this should prevent the cache being deleted should homebridge be shutdown before it has finished launching
if (this.cachedAccessoriesFileLoaded) {
const serializedAccessories = this.cachedPlatformAccessories.map(accessory => PlatformAccessory.serialize(accessory));
this.storageService.setItemSync(this.bridgeOptions.cachedAccessoriesItemName, serializedAccessories);
}
}
catch (error) {
log.error('Failed to save cached accessories to disk:', error.message);
log.error('Your accessories will not persist between restarts until this issue is resolved.');
}
}
handleRegisterPlatformAccessories(accessories) {
const hapAccessories = accessories.map((accessory) => {
// Check for UUID collision with existing bridged accessories
const existingAccessory = this.cachedPlatformAccessories.find(cached => cached._associatedHAPAccessory.UUID === accessory._associatedHAPAccessory.UUID);
if (existingAccessory) {
log.warn('Accessory \'%s\' has the same UUID as existing accessory \'%s\' (UUID: %s). Skipping duplicate.', accessory.displayName, existingAccessory.displayName, accessory._associatedHAPAccessory.UUID);
return undefined;
}
this.cachedPlatformAccessories.push(accessory);
const plugin = this.pluginManager.getPlugin(accessory._associatedPlugin);
if (plugin) {
const platforms = plugin.getActiveDynamicPlatform(accessory._associatedPlatform);
if (!platforms) {
log.warn('The plugin \'%s\' registered a new accessory for the platform \'%s\'. The platform couldn\'t be found though!', accessory._associatedPlugin, accessory._associatedPlatform);
}
accessory._associatedHAPAccessory.on("characteristic-warning" /* AccessoryEventTypes.CHARACTERISTIC_WARNING */, BridgeService.printCharacteristicWriteWarning.bind(this, plugin, accessory._associatedHAPAccessory, {}));
}
else {
log.warn('A platform configured a new accessory under the plugin name \'%s\'. However no loaded plugin could be found for the name!', accessory._associatedPlugin);
}
return accessory._associatedHAPAccessory;
}).filter((hapAccessory) => hapAccessory !== undefined);
this.bridge.addBridgedAccessories(hapAccessories);
this.saveCachedPlatformAccessoriesOnDisk();
}
handleUpdatePlatformAccessories(accessories) {
if (!Array.isArray(accessories)) {
// This could be quite destructive if a non-array is passed in, so we'll just ignore it.
return;
}
const nonUpdatedPlugins = this.cachedPlatformAccessories.filter(cachedPlatformAccessory => (!accessories.some(accessory => accessory.UUID === cachedPlatformAccessory._associatedHAPAccessory.UUID)));
this.cachedPlatformAccessories = [...nonUpdatedPlugins, ...accessories];
// Update persisted accessories
this.saveCachedPlatformAccessoriesOnDisk();
}
handleUnregisterPlatformAccessories(accessories) {
const hapAccessories = accessories.map((accessory) => {
const index = this.cachedPlatformAccessories.indexOf(accessory);
if (index >= 0) {
this.cachedPlatformAccessories.splice(index, 1);
}
return accessory._associatedHAPAccessory;
});
this.bridge.removeBridgedAccessories(hapAccessories);
this.saveCachedPlatformAccessoriesOnDisk();
}
async handlePublishExternalAccessories(accessories) {
// HAP must be enabled to publish external accessories
if (this.bridgeConfig.hap === false) {
log.debug('Skipping external accessory HAP publish: HAP is disabled for this bridge (bridgeConfig.hap=false).');
return;
}
const accessoryPin = this.bridgeConfig.pin;
for (const accessory of accessories) {
const hapAccessory = accessory._associatedHAPAccessory;
const advertiseAddress = generate(hapAccessory.UUID);
// get external port allocation
const accessoryPort = await this.externalPortService.requestPort(advertiseAddress);
if (this.publishedExternalAccessories.has(advertiseAddress)) {
throw new Error(`Accessory ${hapAccessory.displayName} experienced an address collision.`);
}
else {
this.publishedExternalAccessories.set(advertiseAddress, accessory);
}
const plugin = this.pluginManager.getPlugin(accessory._associatedPlugin);
if (plugin) {
hapAccessory.on("characteristic-warning" /* AccessoryEventTypes.CHARACTERISTIC_WARNING */, BridgeService.printCharacteristicWriteWarning.bind(this, plugin, hapAccessory, { ignoreSlow: true }));
}
else if (PluginManager.isQualifiedPluginIdentifier(accessory._associatedPlugin)) {
// we did already complain in api.ts if it wasn't a qualified name
log.warn('A platform configured a external accessory under the plugin name \'%s\'. However no loaded plugin could be found for the name!', accessory._associatedPlugin);
}
hapAccessory.on("listening" /* AccessoryEventTypes.LISTENING */, (port) => {
log.success('%s is running on port %s.', hapAccessory.displayName, port);
log.info('Please add [%s] manually in Home app. Setup Code: %s', hapAccessory.displayName, accessoryPin);
});
const publishInfo = {
username: advertiseAddress,
pincode: accessoryPin,
category: accessory.category,
port: accessoryPort,
bind: this.bridgeConfig.bind,
addIdentifyingMaterial: true,
advertiser: this.bridgeConfig.advertiser,
};
log.debug('Publishing external accessory (name: %s, publishInfo: %o).', hapAccessory.displayName, BridgeService.strippingPinCode(publishInfo));
void hapAccessory.publish(publishInfo, this.allowInsecureAccess);
}
}
createHAPAccessory(plugin, accessoryInstance, displayName, accessoryType, uuidBase) {
const services = (accessoryInstance.getServices() || [])
.filter(service => !!service); // filter out undefined values; a common mistake
const controllers = ((accessoryInstance.getControllers && accessoryInstance.getControllers()) || [])
.filter(controller => !!controller);
if (services.length === 0 && controllers.length === 0) { // check that we only add valid accessory with at least one service
return undefined;
}
// The returned "services" for this accessory are simply an array of new-API-style
// Service instances which we can add to a created HAP-NodeJS Accessory directly.
const accessoryUUID = uuid.generate(`${accessoryType}:${uuidBase || displayName}`);
const accessory = new Accessory(displayName, accessoryUUID);
// listen for the identify event if the accessory instance has defined an identify() method
if (accessoryInstance.identify) {
accessory.on("identify" /* AccessoryEventTypes.IDENTIFY */, (paired, callback) => {
// @ts-expect-error: empty callback for backwards compatibility
accessoryInstance.identify(() => { });
callback();
});
}
const informationService = accessory.getService(Service.AccessoryInformation);
services.forEach((service) => {
// if you returned an AccessoryInformation service, merge its values with ours
if (service instanceof Service.AccessoryInformation) {
service.setCharacteristic(Characteristic.Name, displayName); // ensure display name is set
// ensure the plugin has not hooked already some listeners (some weird ones do).
// Otherwise, they would override our identify listener registered by the HAP-NodeJS accessory
service.getCharacteristic(Characteristic.Identify).removeAllListeners("set" /* CharacteristicEventTypes.SET */);
// pull out any values and listeners (get and set) you may have defined
informationService.replaceCharacteristicsFromService(service);
}
else {
accessory.addService(service);
}
});
accessory.on("characteristic-warning" /* AccessoryEventTypes.CHARACTERISTIC_WARNING */, BridgeService.printCharacteristicWriteWarning.bind(this, plugin, accessory, {}));
controllers.forEach((controller) => {
accessory.configureController(controller);
});
return accessory;
}
async loadPlatformAccessories(plugin, platformInstance, platformType, logger) {
// Plugin 1.0, load accessories
return new Promise((resolve) => {
// warn the user if the static platform is blocking the startup of Homebridge for to long
const loadDelayWarningInterval = setInterval(() => {
log.warn(getLogPrefix(plugin.getPluginIdentifier()), 'This plugin is taking long time to load and preventing Homebridge from starting. See https://homebridge.io/w/JtMGR for more info.');
}, 20000);
platformInstance.accessories(once((accessories) => {
// clear the load delay warning interval
clearInterval(loadDelayWarningInterval);
// loop through accessories adding them to the list and registering them
accessories.forEach((accessoryInstance, index) => {
// @ts-expect-error: assume this property was set
const accessoryName = accessoryInstance.name;
// @ts-expect-error: optional base uuid
const uuidBase = accessoryInstance.uuid_base;
log.info('Initializing platform accessory \'%s\'...', accessoryName);
const accessory = this.createHAPAccessory(plugin, accessoryInstance, accessoryName, platformType, uuidBase);
if (accessory) {
this.bridge.addBridgedAccessory(accessory);
}
else {
logger('Platform %s returned an accessory at index %d with an empty set of services. Won\'t adding it to the bridge!', platformType, index);
}
});
resolve();
}));
});
}
teardown() {
void this.bridge.unpublish();
for (const accessory of this.publishedExternalAccessories.values()) {
void accessory._associatedHAPAccessory.unpublish();
}
this.saveCachedPlatformAccessoriesOnDisk();
// signalShutdown fires last so plugin shutdown listeners run with the
// UPDATE_PLATFORM_ACCESSORIES handler still attached. Plugins may do
// async cleanup (e.g. cancelling subscriptions on exposed devices) and
// call api.updatePlatformAccessories() afterwards; that call needs the
// handler in place to persist any context updates to disk.
this.api.signalShutdown();
}
static strippingPinCode(publishInfo) {
const info = {
...publishInfo,
};
info.pincode = '***-**-***';
return info;
}
}
//# sourceMappingURL=bridgeService.js.map