UNPKG

homebridge

Version:
415 lines 25 kB
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