UNPKG

hap-nodejs

Version:

HAP-NodeJS is a Node.js implementation of HomeKit Accessory Server.

962 lines 86 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Accessory = exports.AccessoryEventTypes = exports.MDNSAdvertiser = exports.CharacteristicWarningType = exports.Categories = void 0; const tslib_1 = require("tslib"); const assert_1 = tslib_1.__importDefault(require("assert")); const crypto_1 = tslib_1.__importDefault(require("crypto")); const debug_1 = tslib_1.__importDefault(require("debug")); const events_1 = require("events"); const net_1 = tslib_1.__importDefault(require("net")); const Advertiser_1 = require("./Advertiser"); // noinspection JSDeprecatedSymbols const Characteristic_1 = require("./Characteristic"); const controller_1 = require("./controller"); const HAPServer_1 = require("./HAPServer"); const AccessoryInfo_1 = require("./model/AccessoryInfo"); const ControllerStorage_1 = require("./model/ControllerStorage"); const IdentifierCache_1 = require("./model/IdentifierCache"); const Service_1 = require("./Service"); const clone_1 = require("./util/clone"); const request_util_1 = require("./util/request-util"); const uuid = tslib_1.__importStar(require("./util/uuid")); const uuid_1 = require("./util/uuid"); const checkName_1 = require("./util/checkName"); const debug = (0, debug_1.default)("HAP-NodeJS:Accessory"); const MAX_ACCESSORIES = 149; // Maximum number of bridged accessories per bridge. const MAX_SERVICES = 100; /** * Known category values. Category is a hint to iOS clients about what "type" of Accessory this represents, for UI only. * * @group Accessory */ var Categories; (function (Categories) { // noinspection JSUnusedGlobalSymbols Categories[Categories["OTHER"] = 1] = "OTHER"; Categories[Categories["BRIDGE"] = 2] = "BRIDGE"; Categories[Categories["FAN"] = 3] = "FAN"; Categories[Categories["GARAGE_DOOR_OPENER"] = 4] = "GARAGE_DOOR_OPENER"; Categories[Categories["LIGHTBULB"] = 5] = "LIGHTBULB"; Categories[Categories["DOOR_LOCK"] = 6] = "DOOR_LOCK"; Categories[Categories["OUTLET"] = 7] = "OUTLET"; Categories[Categories["SWITCH"] = 8] = "SWITCH"; Categories[Categories["THERMOSTAT"] = 9] = "THERMOSTAT"; Categories[Categories["SENSOR"] = 10] = "SENSOR"; Categories[Categories["ALARM_SYSTEM"] = 11] = "ALARM_SYSTEM"; // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values Categories[Categories["SECURITY_SYSTEM"] = 11] = "SECURITY_SYSTEM"; Categories[Categories["DOOR"] = 12] = "DOOR"; Categories[Categories["WINDOW"] = 13] = "WINDOW"; Categories[Categories["WINDOW_COVERING"] = 14] = "WINDOW_COVERING"; Categories[Categories["PROGRAMMABLE_SWITCH"] = 15] = "PROGRAMMABLE_SWITCH"; Categories[Categories["RANGE_EXTENDER"] = 16] = "RANGE_EXTENDER"; Categories[Categories["CAMERA"] = 17] = "CAMERA"; // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values Categories[Categories["IP_CAMERA"] = 17] = "IP_CAMERA"; Categories[Categories["VIDEO_DOORBELL"] = 18] = "VIDEO_DOORBELL"; Categories[Categories["AIR_PURIFIER"] = 19] = "AIR_PURIFIER"; Categories[Categories["AIR_HEATER"] = 20] = "AIR_HEATER"; Categories[Categories["AIR_CONDITIONER"] = 21] = "AIR_CONDITIONER"; Categories[Categories["AIR_HUMIDIFIER"] = 22] = "AIR_HUMIDIFIER"; Categories[Categories["AIR_DEHUMIDIFIER"] = 23] = "AIR_DEHUMIDIFIER"; Categories[Categories["APPLE_TV"] = 24] = "APPLE_TV"; Categories[Categories["HOMEPOD"] = 25] = "HOMEPOD"; Categories[Categories["SPEAKER"] = 26] = "SPEAKER"; Categories[Categories["AIRPORT"] = 27] = "AIRPORT"; Categories[Categories["SPRINKLER"] = 28] = "SPRINKLER"; Categories[Categories["FAUCET"] = 29] = "FAUCET"; Categories[Categories["SHOWER_HEAD"] = 30] = "SHOWER_HEAD"; Categories[Categories["TELEVISION"] = 31] = "TELEVISION"; Categories[Categories["TARGET_CONTROLLER"] = 32] = "TARGET_CONTROLLER"; Categories[Categories["ROUTER"] = 33] = "ROUTER"; Categories[Categories["AUDIO_RECEIVER"] = 34] = "AUDIO_RECEIVER"; Categories[Categories["TV_SET_TOP_BOX"] = 35] = "TV_SET_TOP_BOX"; Categories[Categories["TV_STREAMING_STICK"] = 36] = "TV_STREAMING_STICK"; })(Categories || (exports.Categories = Categories = {})); /** * @group Accessory */ var CharacteristicWarningType; (function (CharacteristicWarningType) { CharacteristicWarningType["SLOW_WRITE"] = "slow-write"; CharacteristicWarningType["TIMEOUT_WRITE"] = "timeout-write"; CharacteristicWarningType["SLOW_READ"] = "slow-read"; CharacteristicWarningType["TIMEOUT_READ"] = "timeout-read"; CharacteristicWarningType["WARN_MESSAGE"] = "warn-message"; CharacteristicWarningType["ERROR_MESSAGE"] = "error-message"; CharacteristicWarningType["DEBUG_MESSAGE"] = "debug-message"; })(CharacteristicWarningType || (exports.CharacteristicWarningType = CharacteristicWarningType = {})); /** * @group Accessory */ var MDNSAdvertiser; (function (MDNSAdvertiser) { /** * Use the `@homebridge/ciao` module as advertiser. */ MDNSAdvertiser["CIAO"] = "ciao"; /** * Use the `bonjour-hap` module as advertiser. */ MDNSAdvertiser["BONJOUR"] = "bonjour-hap"; /** * Use Avahi/D-Bus as advertiser. */ MDNSAdvertiser["AVAHI"] = "avahi"; /** * Use systemd-resolved/D-Bus as advertiser. * * Note: The systemd-resolved D-Bus interface doesn't provide means to detect restarts of the service. * Therefore, we can't detect if our advertisement might be lost due to a restart of the systemd-resolved daemon restart. * Consequentially, treat this feature as an experimental feature. */ MDNSAdvertiser["RESOLVED"] = "resolved"; })(MDNSAdvertiser || (exports.MDNSAdvertiser = MDNSAdvertiser = {})); var WriteRequestState; (function (WriteRequestState) { WriteRequestState[WriteRequestState["REGULAR_REQUEST"] = 0] = "REGULAR_REQUEST"; WriteRequestState[WriteRequestState["TIMED_WRITE_AUTHENTICATED"] = 1] = "TIMED_WRITE_AUTHENTICATED"; WriteRequestState[WriteRequestState["TIMED_WRITE_REJECTED"] = 2] = "TIMED_WRITE_REJECTED"; })(WriteRequestState || (WriteRequestState = {})); /** * @group Accessory */ var AccessoryEventTypes; (function (AccessoryEventTypes) { /** * Emitted when an iOS device wishes for this Accessory to identify itself. If `paired` is false, then * this device is currently browsing for Accessories in the system-provided "Add Accessory" screen. If * `paired` is true, then this is a device that has already paired with us. Note that if `paired` is true, * listening for this event is a shortcut for the underlying mechanism of setting the `Identify` Characteristic: * `getService(Service.AccessoryInformation).getCharacteristic(Characteristic.Identify).on('set', ...)` * You must call the callback for identification to be successful. */ AccessoryEventTypes["IDENTIFY"] = "identify"; /** * This event is emitted once the HAP TCP socket is bound. * At this point the mdns advertisement isn't yet available. Use the {@link ADVERTISED} if you require the accessory to be discoverable. */ AccessoryEventTypes["LISTENING"] = "listening"; /** * This event is emitted once the mDNS suite has fully advertised the presence of the accessory. * This event is guaranteed to be called after {@link LISTENING}. */ AccessoryEventTypes["ADVERTISED"] = "advertised"; AccessoryEventTypes["SERVICE_CONFIGURATION_CHANGE"] = "service-configurationChange"; /** * Emitted after a change in the value of one of the provided Service's Characteristics. */ AccessoryEventTypes["SERVICE_CHARACTERISTIC_CHANGE"] = "service-characteristic-change"; AccessoryEventTypes["PAIRED"] = "paired"; AccessoryEventTypes["UNPAIRED"] = "unpaired"; AccessoryEventTypes["CHARACTERISTIC_WARNING"] = "characteristic-warning"; })(AccessoryEventTypes || (exports.AccessoryEventTypes = AccessoryEventTypes = {})); /** * Accessory is a virtual HomeKit device. It can publish an associated HAP server for iOS devices to communicate * with - or it can run behind another "Bridge" Accessory server. * * Bridged Accessories in this implementation must have a UUID that is unique among all other Accessories that * are hosted by the Bridge. This UUID must be "stable" and unchanging, even when the server is restarted. This * is required so that the Bridge can provide consistent "Accessory IDs" (aid) and "Instance IDs" (iid) for all * Accessories, Services, and Characteristics for iOS clients to reference later. * * @group Accessory */ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging class Accessory extends events_1.EventEmitter { displayName; UUID; // Timeout in milliseconds until a characteristic warning is issue static TIMEOUT_WARNING = 3000; // Timeout in milliseconds after `TIMEOUT_WARNING` until the operation on the characteristic is considered timed out. static TIMEOUT_AFTER_WARNING = 6000; // NOTICE: when adding/changing properties, remember to possibly adjust the serialize/deserialize functions aid = null; // assigned by us in assignIDs() or by a Bridge _isBridge = false; // true if we are a Bridge (creating a new instance of the Bridge subclass sets this to true) bridged = false; // true if we are hosted "behind" a Bridge Accessory bridge; // if accessory is bridged, this property points to the bridge which bridges this accessory bridgedAccessories = []; // If we are a Bridge, these are the Accessories we are bridging reachable = true; lastKnownUsername; category = 1 /* Categories.OTHER */; services = []; primaryService; shouldPurgeUnusedIDs = true; // Purge unused ids by default /** * Captures if initialization steps inside {@link publish} have been called. * This is important when calling {@link publish} multiple times (e.g. after calling {@link unpublish}). * @private Private API */ initialized = false; controllers = {}; serializedControllers; // store uninitialized controller data after a Accessory.deserialize call activeCameraController; /** * @private Private API. */ _accessoryInfo; /** * @private Private API. */ _setupID = null; /** * @private Private API. */ _identifierCache; /** * @private Private API. */ controllerStorage = new ControllerStorage_1.ControllerStorage(this); /** * @private Private API. */ _advertiser; /** * @private Private API. */ _server; /** * @private Private API. */ _setupURI; configurationChangeDebounceTimeout; /** * This property captures the time when we last served a /accessories request. * For multiple bursts of /accessories request we don't want to always contact GET handlers */ lastAccessoriesRequest = 0; constructor(displayName, UUID) { super(); this.displayName = displayName; this.UUID = UUID; (0, assert_1.default)(displayName, "Accessories must be created with a non-empty displayName."); (0, assert_1.default)(UUID, "Accessories must be created with a valid UUID."); (0, assert_1.default)(uuid.isValid(UUID), "UUID '" + UUID + "' is not a valid UUID. Try using the provided 'generateUUID' function to create a " + "valid UUID from any arbitrary string, like a serial number."); // create our initial "Accessory Information" Service that all Accessories are expected to have (0, checkName_1.checkName)(this.displayName, "Name", displayName); this.addService(Service_1.Service.AccessoryInformation) .setCharacteristic(Characteristic_1.Characteristic.Name, displayName); // sign up for when iOS attempts to "set" the `Identify` characteristic - this means a paired device wishes // for us to identify ourselves (as opposed to an unpaired device - that case is handled by HAPServer 'identify' event) this.getService(Service_1.Service.AccessoryInformation) .getCharacteristic(Characteristic_1.Characteristic.Identify) .on("set" /* CharacteristicEventTypes.SET */, (value, callback) => { if (value) { const paired = true; this.identificationRequest(paired, callback); } }); } identificationRequest(paired, callback) { debug("[%s] Identification request", this.displayName); if (this.listeners("identify" /* AccessoryEventTypes.IDENTIFY */).length > 0) { // allow implementors to identify this Accessory in whatever way is appropriate, and pass along // the standard callback for completion. this.emit("identify" /* AccessoryEventTypes.IDENTIFY */, paired, callback); } else { debug("[%s] Identification request ignored; no listeners to 'identify' event", this.displayName); callback(); } } // eslint-disable-next-line @typescript-eslint/no-explicit-any addService(serviceParam, ...constructorArgs) { // service might be a constructor like `Service.AccessoryInformation` instead of an instance // of Service. Coerce if necessary. const service = typeof serviceParam === "function" ? new serviceParam(constructorArgs[0], constructorArgs[1], constructorArgs[2]) : serviceParam; // check for UUID+subtype conflict for (const existing of this.services) { if (existing.UUID === service.UUID) { // OK we have two Services with the same UUID. Check that each defines a `subtype` property and that each is unique. if (!service.subtype) { throw new Error("Cannot add a Service with the same UUID '" + existing.UUID + "' as another Service in this Accessory without also defining a unique 'subtype' property."); } if (service.subtype === existing.subtype) { throw new Error("Cannot add a Service with the same UUID '" + existing.UUID + "' and subtype '" + existing.subtype + "' as another Service in this Accessory."); } } } if (this.services.length >= MAX_SERVICES) { throw new Error("Cannot add more than " + MAX_SERVICES + " services to a single accessory!"); } this.services.push(service); if (service.isPrimaryService) { // check if a primary service was added if (this.primaryService !== undefined) { this.primaryService.isPrimaryService = false; } this.primaryService = service; } if (!this.bridged) { this.enqueueConfigurationUpdate(); } else { this.emit("service-configurationChange" /* AccessoryEventTypes.SERVICE_CONFIGURATION_CHANGE */, { service: service }); } this.setupServiceEventHandlers(service); return service; } removeService(service) { const index = this.services.indexOf(service); if (index >= 0) { this.services.splice(index, 1); if (this.primaryService === service) { // check if we are removing out primary service this.primaryService = undefined; } this.removeLinkedService(service); // remove it from linked service entries on the local accessory if (!this.bridged) { this.enqueueConfigurationUpdate(); } else { this.emit("service-configurationChange" /* AccessoryEventTypes.SERVICE_CONFIGURATION_CHANGE */, { service: service }); } service.removeAllListeners(); } } removeLinkedService(removed) { for (const service of this.services) { service.removeLinkedService(removed); } } getService(name) { for (const service of this.services) { if (typeof name === "string" && (service.displayName === name || service.name === name || service.subtype === name)) { return service; } else { // @ts-expect-error ('UUID' does not exist on type 'never') if (typeof name === "function" && ((service instanceof name) || (name.UUID === service.UUID))) { return service; } } } return undefined; } getServiceById(uuid, subType) { for (const service of this.services) { if (typeof uuid === "string" && (service.displayName === uuid || service.name === uuid) && service.subtype === subType) { return service; } else { // @ts-expect-error ('UUID' does not exist on type 'never') if (typeof uuid === "function" && ((service instanceof uuid) || (uuid.UUID === service.UUID)) && service.subtype === subType) { return service; } } } return undefined; } /** * Returns the bridging accessory if this accessory is bridged. * Otherwise, returns itself. * * @returns the primary accessory */ getPrimaryAccessory = () => { return this.bridged ? this.bridge : this; }; addBridgedAccessory(accessory, deferUpdate = false) { if (accessory._isBridge || accessory === this) { throw new Error("Illegal state: either trying to bridge a bridge or trying to bridge itself!"); } if (accessory.initialized) { throw new Error("Tried to bridge an accessory which was already published once!"); } if (accessory.bridge != null) { // this also prevents that we bridge the same accessory twice! throw new Error("Tried to bridge " + accessory.displayName + " while it was already bridged by " + accessory.bridge.displayName); } if (this.bridgedAccessories.length >= MAX_ACCESSORIES) { throw new Error("Cannot Bridge more than " + MAX_ACCESSORIES + " Accessories"); } // listen for changes in ANY characteristics of ANY services on this Accessory accessory.on("service-characteristic-change" /* AccessoryEventTypes.SERVICE_CHARACTERISTIC_CHANGE */, change => this.handleCharacteristicChangeEvent(accessory, change.service, change)); accessory.on("service-configurationChange" /* AccessoryEventTypes.SERVICE_CONFIGURATION_CHANGE */, this.enqueueConfigurationUpdate.bind(this)); accessory.on("characteristic-warning" /* AccessoryEventTypes.CHARACTERISTIC_WARNING */, this.handleCharacteristicWarning.bind(this)); accessory.bridged = true; accessory.bridge = this; this.bridgedAccessories.push(accessory); this.controllerStorage.linkAccessory(accessory); // init controllers of bridged accessory if (!deferUpdate) { this.enqueueConfigurationUpdate(); } return accessory; } addBridgedAccessories(accessories) { for (const accessory of accessories) { this.addBridgedAccessory(accessory, true); } this.enqueueConfigurationUpdate(); } removeBridgedAccessory(accessory, deferUpdate = false) { // check for UUID conflict const accessoryIndex = this.bridgedAccessories.indexOf(accessory); if (accessoryIndex === -1) { throw new Error("Cannot find the bridged Accessory to remove."); } this.bridgedAccessories.splice(accessoryIndex, 1); accessory.bridged = false; accessory.bridge = undefined; accessory.removeAllListeners(); if (!deferUpdate) { this.enqueueConfigurationUpdate(); } } removeBridgedAccessories(accessories) { for (const accessory of accessories) { this.removeBridgedAccessory(accessory, true); } this.enqueueConfigurationUpdate(); } removeAllBridgedAccessories() { for (let i = this.bridgedAccessories.length - 1; i >= 0; i--) { this.removeBridgedAccessory(this.bridgedAccessories[i], true); } this.enqueueConfigurationUpdate(); } getCharacteristicByIID(iid) { for (const service of this.services) { const characteristic = service.getCharacteristicByIID(iid); if (characteristic) { return characteristic; } } } getAccessoryByAID(aid) { if (this.aid === aid) { return this; } return this.bridgedAccessories.find(value => value.aid === aid); } findCharacteristic(aid, iid) { const accessory = this.getAccessoryByAID(aid); return accessory && accessory.getCharacteristicByIID(iid); } /** * This method is used to set up a new Controller for this accessory. See {@link Controller} for a more detailed * explanation what a Controller is and what it is capable of. * * The controller can be passed as an instance of the class or as a constructor (without any necessary parameters) * for a new Controller. * Only one Controller of a given {@link ControllerIdentifier} can be configured for a given Accessory. * * When called, it will be checked if there are any services and persistent data the Controller (for the given * {@link ControllerIdentifier}) can be restored from. Otherwise, the Controller will be created with new services. * * * @param controllerConstructor - The Controller instance or constructor to the Controller with no required arguments. */ configureController(controllerConstructor) { const controller = typeof controllerConstructor === "function" ? new controllerConstructor() // any custom constructor arguments should be passed before using .bind(...) : controllerConstructor; const id = controller.controllerId(); if (this.controllers[id]) { throw new Error(`A Controller with the type/id '${id}' was already added to the accessory ${this.displayName}`); } const savedServiceMap = this.serializedControllers && this.serializedControllers[id]; let serviceMap; if (savedServiceMap) { // we found data to restore from const clonedServiceMap = (0, clone_1.clone)(savedServiceMap); const updatedServiceMap = controller.initWithServices(savedServiceMap); // init controller with existing services serviceMap = updatedServiceMap || savedServiceMap; // initWithServices could return an updated serviceMap, otherwise just use the existing one if (updatedServiceMap) { // controller returned a ServiceMap and thus signaled an updated set of services // clonedServiceMap is altered by this method, should not be touched again after this call (for the future people) this.handleUpdatedControllerServiceMap(clonedServiceMap, updatedServiceMap); } controller.configureServices(); // let the controller setup all its handlers // remove serialized data from our dictionary: delete this.serializedControllers[id]; if (Object.entries(this.serializedControllers).length === 0) { this.serializedControllers = undefined; } } else { serviceMap = controller.constructServices(); // let the controller create his services controller.configureServices(); // let the controller setup all its handlers Object.values(serviceMap).forEach(service => { if (service && !this.services.includes(service)) { this.addService(service); } }); } // --- init handlers and setup context --- const context = { controller: controller, serviceMap: serviceMap, }; if ((0, controller_1.isSerializableController)(controller)) { this.controllerStorage.trackController(controller); } this.controllers[id] = context; if (controller instanceof controller_1.CameraController) { // save CameraController for Snapshot handling this.activeCameraController = controller; } } /** * This method will remove a given Controller from this accessory. * The controller object will be restored to its initial state. * This also means that any event handlers setup for the controller will be removed. * * @param controller - The controller which should be removed from the accessory. */ removeController(controller) { const id = controller.controllerId(); const storedController = this.controllers[id]; if (storedController) { if (storedController.controller !== controller) { throw new Error("[" + this.displayName + "] tried removing a controller with the id/type '" + id + "' though provided controller isn't the same instance that is registered!"); } if ((0, controller_1.isSerializableController)(controller)) { // this will reset the state change delegate before we call handleControllerRemoved() this.controllerStorage.untrackController(controller); } if (controller.handleFactoryReset) { controller.handleFactoryReset(); } controller.handleControllerRemoved(); delete this.controllers[id]; if (this.activeCameraController === controller) { this.activeCameraController = undefined; } Object.values(storedController.serviceMap).forEach(service => { if (service) { this.removeService(service); } }); } if (this.serializedControllers) { delete this.serializedControllers[id]; } } handleAccessoryUnpairedForControllers() { for (const context of Object.values(this.controllers)) { const controller = context.controller; if (controller.handleFactoryReset) { // if the controller implements handleFactoryReset, setup event handlers for this controller controller.handleFactoryReset(); } if ((0, controller_1.isSerializableController)(controller)) { this.controllerStorage.purgeControllerData(controller); } } } handleUpdatedControllerServiceMap(originalServiceMap, updatedServiceMap) { updatedServiceMap = (0, clone_1.clone)(updatedServiceMap); // clone it so we can alter it Object.keys(originalServiceMap).forEach(name => { const service = originalServiceMap[name]; const updatedService = updatedServiceMap[name]; if (service && updatedService) { // we check all names contained in both ServiceMaps for changes delete originalServiceMap[name]; // delete from original ServiceMap, so it will only contain deleted services at the end delete updatedServiceMap[name]; // delete from updated ServiceMap, so it will only contain added services at the end if (service !== updatedService) { this.removeService(service); this.addService(updatedService); } } }); // now originalServiceMap contains only deleted services and updateServiceMap only added services Object.values(originalServiceMap).forEach(service => { if (service) { this.removeService(service); } }); Object.values(updatedServiceMap).forEach(service => { if (service) { this.addService(service); } }); } setupURI() { if (this._setupURI) { return this._setupURI; } (0, assert_1.default)(!!this._accessoryInfo, "Cannot generate setupURI on an accessory that isn't published yet!"); const buffer = Buffer.alloc(8); let value_low = parseInt(this._accessoryInfo.pincode.replace(/-/g, ""), 10); const value_high = this._accessoryInfo.category >> 1; value_low |= 1 << 28; // Supports IP; buffer.writeUInt32BE(value_low, 4); if (this._accessoryInfo.category & 1) { buffer[4] = buffer[4] | 1 << 7; } buffer.writeUInt32BE(value_high, 0); let encodedPayload = (buffer.readUInt32BE(4) + (buffer.readUInt32BE(0) * 0x100000000)).toString(36).toUpperCase(); if (encodedPayload.length !== 9) { for (let i = 0; i <= 9 - encodedPayload.length; i++) { encodedPayload = "0" + encodedPayload; } } this._setupURI = "X-HM://" + encodedPayload + this._setupID; return this._setupURI; } /** * This method is called right before the accessory is published. It should be used to check for common * mistakes in Accessory structured, which may lead to HomeKit rejecting the accessory when pairing. * If it is called on a bridge it will call this method for all bridged accessories. */ validateAccessory(mainAccessory) { const service = this.getService(Service_1.Service.AccessoryInformation); if (!service) { console.log("HAP-NodeJS WARNING: The accessory '" + this.displayName + "' is getting published without a AccessoryInformation service. " + "This might prevent the accessory from being added to the Home app or leading to the accessory being unresponsive!"); } else { // eslint-disable-next-line @typescript-eslint/no-explicit-any const checkValue = (name, value) => { if (!value) { console.log("HAP-NodeJS WARNING: The accessory '" + this.displayName + "' is getting published with the characteristic '" + name + "'" + " (of the AccessoryInformation service) not having a value set. " + "This might prevent the accessory from being added to the Home App or leading to the accessory being unresponsive!"); } }; (0, checkName_1.checkName)(this.displayName, "Name", service.getCharacteristic(Characteristic_1.Characteristic.Name).value); checkValue("FirmwareRevision", service.getCharacteristic(Characteristic_1.Characteristic.FirmwareRevision).value); checkValue("Manufacturer", service.getCharacteristic(Characteristic_1.Characteristic.Manufacturer).value); checkValue("Model", service.getCharacteristic(Characteristic_1.Characteristic.Model).value); checkValue("Name", service.getCharacteristic(Characteristic_1.Characteristic.Name).value); checkValue("SerialNumber", service.getCharacteristic(Characteristic_1.Characteristic.SerialNumber).value); } if (mainAccessory) { // the main accessory which is advertised via bonjour must have a name with length <= 63 (limitation of DNS FQDN names) (0, assert_1.default)(Buffer.from(this.displayName, "utf8").length <= 63, "Accessory displayName cannot be longer than 63 bytes!"); } if (this.bridged) { this.bridgedAccessories.forEach(accessory => accessory.validateAccessory()); } } /** * Assigns aid/iid to ourselves, any Accessories we are bridging, and all associated Services+Characteristics. Uses * the provided identifierCache to keep IDs stable. * @private Private API */ _assignIDs(identifierCache) { // if we are responsible for our own identifierCache, start the expiration process // also check weather we want to have an expiration process if (this._identifierCache && this.shouldPurgeUnusedIDs) { this._identifierCache.startTrackingUsage(); } if (this.bridged) { // This Accessory is bridged, so it must have an aid > 1. Use the provided identifierCache to // fetch or assign one based on our UUID. this.aid = identifierCache.getAID(this.UUID); } else { // Since this Accessory is the server (as opposed to any Accessories that may be bridged behind us), // we must have aid = 1 this.aid = 1; } for (const service of this.services) { if (this._isBridge) { service._assignIDs(identifierCache, this.UUID, 2000000000); } else { service._assignIDs(identifierCache, this.UUID); } } // now assign IDs for any Accessories we are bridging for (const accessory of this.bridgedAccessories) { accessory._assignIDs(identifierCache); } // expire any now-unused cache keys (for Accessories, Services, or Characteristics // that have been removed since the last call to assignIDs()) if (this._identifierCache) { //Check weather we want to purge the unused ids if (this.shouldPurgeUnusedIDs) { this._identifierCache.stopTrackingUsageAndExpireUnused(); } //Save in case we have new ones this._identifierCache.save(); } } disableUnusedIDPurge() { this.shouldPurgeUnusedIDs = false; } enableUnusedIDPurge() { this.shouldPurgeUnusedIDs = true; } /** * Manually purge the unused ids if you like, comes handy * when you have disabled auto purge, so you can do it manually */ purgeUnusedIDs() { //Cache the state of the purge mechanism and set it to true const oldValue = this.shouldPurgeUnusedIDs; this.shouldPurgeUnusedIDs = true; //Reassign all ids this._assignIDs(this._identifierCache); // Revert the purge mechanism state this.shouldPurgeUnusedIDs = oldValue; } /** * Returns a JSON representation of this accessory suitable for delivering to HAP clients. */ async toHAP(connection, contactGetHandlers = true) { (0, assert_1.default)(this.aid, "aid cannot be undefined for accessory '" + this.displayName + "'"); (0, assert_1.default)(this.services.length, "accessory '" + this.displayName + "' does not have any services!"); const accessory = { aid: this.aid, services: await Promise.all(this.services.map(service => service.toHAP(connection, contactGetHandlers))), }; const accessories = [accessory]; if (!this.bridged) { accessories.push(...await Promise.all(this.bridgedAccessories .map(accessory => accessory.toHAP(connection, contactGetHandlers).then(value => value[0])))); } return accessories; } /** * Returns a JSON representation of this accessory without characteristic values. */ internalHAPRepresentation(assignIds = true) { if (assignIds) { this._assignIDs(this._identifierCache); // make sure our aid/iid's are all assigned } (0, assert_1.default)(this.aid, "aid cannot be undefined for accessory '" + this.displayName + "'"); (0, assert_1.default)(this.services.length, "accessory '" + this.displayName + "' does not have any services!"); const accessory = { aid: this.aid, services: this.services.map(service => service.internalHAPRepresentation()), }; const accessories = [accessory]; if (!this.bridged) { for (const accessory of this.bridgedAccessories) { accessories.push(accessory.internalHAPRepresentation(false)[0]); } } return accessories; } /** * Publishes this accessory on the local network for iOS clients to communicate with. * - `info.username` - formatted as a MAC address, like `CC:22:3D:E3:CE:F6`, of this accessory. * Must be globally unique from all Accessories on your local network. * - `info.pincode` - the 8-digit pin code for clients to use when pairing this Accessory. * Must be formatted as a string like `031-45-154`. * - `info.category` - one of the values of the `Accessory.Category` enum, like `Accessory.Category.SWITCH`. * This is a hint to iOS clients about what "type" of Accessory this represents, so * that for instance an appropriate icon can be drawn for the user while adding a * new Accessory. * @param {{ * username: string; * pincode: string; * category: Accessory.Categories; * }} info - Required info for publishing. * @param {boolean} allowInsecureRequest - Will allow unencrypted and unauthenticated access to the http server */ async publish(info, allowInsecureRequest) { if (this.bridged) { throw new Error("Can't publish in accessory which is bridged by another accessory. Bridged by " + this.bridge?.displayName); } let service = this.getService(Service_1.Service.ProtocolInformation); if (!service) { service = this.addService(Service_1.Service.ProtocolInformation); // add the protocol information service to the primary accessory } service.setCharacteristic(Characteristic_1.Characteristic.Version, Advertiser_1.CiaoAdvertiser.protocolVersionService); if (this.lastKnownUsername && this.lastKnownUsername !== info.username) { // username changed since last publish Accessory.cleanupAccessoryData(this.lastKnownUsername); // delete old Accessory data } if (!this.initialized && (info.addIdentifyingMaterial ?? true)) { // adding some identifying material to our displayName if it's our first publish() call this.displayName = this.displayName + " " + crypto_1.default.createHash("sha512") .update(info.username, "utf8") .digest("hex").slice(0, 4).toUpperCase(); this.getService(Service_1.Service.AccessoryInformation).updateCharacteristic(Characteristic_1.Characteristic.Name, this.displayName); } // attempt to load existing AccessoryInfo from disk this._accessoryInfo = AccessoryInfo_1.AccessoryInfo.load(info.username); // if we don't have one, create a new one. if (!this._accessoryInfo) { debug("[%s] Creating new AccessoryInfo for our HAP server", this.displayName); this._accessoryInfo = AccessoryInfo_1.AccessoryInfo.create(info.username); } if (info.setupID) { this._setupID = info.setupID; } else if (this._accessoryInfo.setupID === undefined || this._accessoryInfo.setupID === "") { this._setupID = Accessory._generateSetupID(); } else { this._setupID = this._accessoryInfo.setupID; } this._accessoryInfo.setupID = this._setupID; // make sure we have up-to-date values in AccessoryInfo, then save it in case they changed (or if we just created it) this._accessoryInfo.displayName = this.displayName; this._accessoryInfo.model = this.getService(Service_1.Service.AccessoryInformation).getCharacteristic(Characteristic_1.Characteristic.Model).value; this._accessoryInfo.category = info.category || 1 /* Categories.OTHER */; this._accessoryInfo.pincode = info.pincode; this._accessoryInfo.save(); // create our IdentifierCache, so we can provide clients with stable aid/iid's this._identifierCache = IdentifierCache_1.IdentifierCache.load(info.username); // if we don't have one, create a new one. if (!this._identifierCache) { debug("[%s] Creating new IdentifierCache", this.displayName); this._identifierCache = new IdentifierCache_1.IdentifierCache(info.username); } // If it's bridge and there are no accessories already assigned to the bridge // probably purge is not needed since it's going to delete all the ids // of accessories that might be added later. Useful when dynamically adding // accessories. if (this._isBridge && this.bridgedAccessories.length === 0) { this.disableUnusedIDPurge(); this.controllerStorage.purgeUnidentifiedAccessoryData = false; } if (!this.initialized) { // controller storage is only loaded from disk the first time we publish! this.controllerStorage.load(info.username); // initializing controller data } // assign aid/iid this._assignIDs(this._identifierCache); // get our accessory information in HAP format and determine if our configuration (that is, our // Accessories/Services/Characteristics) has changed since the last time we were published. make // sure to omit actual values since these are not part of the "configuration". const config = this.internalHAPRepresentation(false); // TODO ensure this stuff is ordered // TODO queue this check until about 5 seconds after startup, allowing some last changes after the publish call // without constantly incrementing the current config number this._accessoryInfo.checkForCurrentConfigurationNumberIncrement(config, true); this.validateAccessory(true); // create our Advertiser which broadcasts our presence over mdns const parsed = Accessory.parseBindOption(info); let selectedAdvertiser = info.advertiser ?? "bonjour-hap" /* MDNSAdvertiser.BONJOUR */; if ((info.advertiser === "avahi" /* MDNSAdvertiser.AVAHI */ && !await Advertiser_1.AvahiAdvertiser.isAvailable()) || (info.advertiser === "resolved" /* MDNSAdvertiser.RESOLVED */ && !await Advertiser_1.ResolvedAdvertiser.isAvailable())) { console.error(`[${this.displayName}] The selected advertiser, "${info.advertiser}", isn't available on this platform. ` + `Reverting to "${"bonjour-hap" /* MDNSAdvertiser.BONJOUR */}"`); selectedAdvertiser = "bonjour-hap" /* MDNSAdvertiser.BONJOUR */; } switch (selectedAdvertiser) { case "ciao" /* MDNSAdvertiser.CIAO */: this._advertiser = new Advertiser_1.CiaoAdvertiser(this._accessoryInfo, { interface: parsed.advertiserAddress, }, { restrictedAddresses: parsed.serviceRestrictedAddress, disabledIpv6: parsed.serviceDisableIpv6, }); break; case "bonjour-hap" /* MDNSAdvertiser.BONJOUR */: this._advertiser = new Advertiser_1.BonjourHAPAdvertiser(this._accessoryInfo, { restrictedAddresses: parsed.serviceRestrictedAddress, disabledIpv6: parsed.serviceDisableIpv6, }); break; case "avahi" /* MDNSAdvertiser.AVAHI */: this._advertiser = new Advertiser_1.AvahiAdvertiser(this._accessoryInfo); break; case "resolved" /* MDNSAdvertiser.RESOLVED */: this._advertiser = new Advertiser_1.ResolvedAdvertiser(this._accessoryInfo); break; default: throw new Error("Unsupported advertiser setting: '" + info.advertiser + "'"); } this._advertiser.on("updated-name" /* AdvertiserEvent.UPDATED_NAME */, name => { this.displayName = name; if (this._accessoryInfo) { this._accessoryInfo.displayName = name; this._accessoryInfo.save(); } // bonjour service name MUST match the name in the accessory information service this.getService(Service_1.Service.AccessoryInformation) .updateCharacteristic(Characteristic_1.Characteristic.Name, name); }); // create our HAP server which handles all communication between iOS devices and us this._server = new HAPServer_1.HAPServer(this._accessoryInfo); this._server.allowInsecureRequest = !!allowInsecureRequest; this._server.on("listening" /* HAPServerEventTypes.LISTENING */, this.onListening.bind(this)); this._server.on("identify" /* HAPServerEventTypes.IDENTIFY */, this.identificationRequest.bind(this, false)); this._server.on("pair" /* HAPServerEventTypes.PAIR */, this.handleInitialPairSetupFinished.bind(this)); this._server.on("add-pairing" /* HAPServerEventTypes.ADD_PAIRING */, this.handleAddPairing.bind(this)); this._server.on("remove-pairing" /* HAPServerEventTypes.REMOVE_PAIRING */, this.handleRemovePairing.bind(this)); this._server.on("list-pairings" /* HAPServerEventTypes.LIST_PAIRINGS */, this.handleListPairings.bind(this)); this._server.on("accessories" /* HAPServerEventTypes.ACCESSORIES */, this.handleAccessories.bind(this)); this._server.on("get-characteristics" /* HAPServerEventTypes.GET_CHARACTERISTICS */, this.handleGetCharacteristics.bind(this)); this._server.on("set-characteristics" /* HAPServerEventTypes.SET_CHARACTERISTICS */, this.handleSetCharacteristics.bind(this)); this._server.on("connection-closed" /* HAPServerEventTypes.CONNECTION_CLOSED */, this.handleHAPConnectionClosed.bind(this)); this._server.on("request-resource" /* HAPServerEventTypes.REQUEST_RESOURCE */, this.handleResource.bind(this)); this._server.listen(info.port, parsed.serverAddress); this.initialized = true; } /** * Removes this Accessory from the local network * Accessory object will no longer valid after invoking this method * Trying to invoke publish() on the object will result undefined behavior */ destroy() { const promise = this.unpublish(); if (this._accessoryInfo) { Accessory.cleanupAccessoryData(this._accessoryInfo.username); this._accessoryInfo = undefined; this._identifierCache = undefined; this.controllerStorage = new ControllerStorage_1.ControllerStorage(this); } this.removeAllListeners(); return promise; } async unpublish() { if (this._server) { this._server.destroy(); this._server = undefined; } if (this._advertiser) { // noinspection JSIgnoredPromiseFromCall await this._advertiser.destroy(); this._advertiser = undefined; } } enqueueConfigurationUpdate() { if (this.configurationChangeDebounceTimeout) { return; // already enqueued } this.configurationChangeDebounceTimeout = setTimeout(() => { this.configurationChangeDebounceTimeout = undefined; if (this._advertiser && this._advertiser) { // get our accessory information in HAP format and determine if our configuration (that is, our // Accessories/Services/Characteristics) has changed since the last time we were published. make // sure to omit actual values since these are not part of the "configuration". const config = this.internalHAPRepresentation(); // TODO ensure this stuff is ordered if (this._accessoryInfo?.checkForCurrentConfigurationNumberIncrement(config)) { this._advertiser.updateAdvertisement(); } } }, 1000); this.configurationChangeDebounceTimeout.unref(); // 1s is fine, HomeKit is built that with configuration updates no iid or aid conflicts occur. // Thus, the only thing happening when the txt update arrives late is already removed accessories/services // not responding or new accessories/services not yet shown } onListening(port, hostname) { (0, assert_1.default)(this._advertiser, "Advertiser wasn't created at onListening!"); // the HAP server is listening, so we can now start advertising our presence. this._advertiser.initPort(port); this._advertiser.startAdvertising() .then(() => this.emit("advertised" /* AccessoryEventTypes.ADVERTISED */)) .catch(reason => { console.error("Could not create mDNS advertisement. The HAP-Server won't be discoverable: " + reason); if (reason.stack) { debug("Detailed error: " + reason.stack); } }); this.emit("listening" /* AccessoryEventTypes.LISTENING */, port, hostname); } handleInitialPairSetupFinished(username, publicKey, callback) { debug("[%s] Paired with client %s", this.displayName, username); this._accessoryInfo && this._accessoryInfo.addPairedClient(username, publicKey, 1 /* PermissionTypes.ADMIN */); this._accessoryInfo && this._accessoryInfo.save(); // update our advertisement, so it can pick up on the paired status of AccessoryInfo this._advertiser && this._advertiser.updateAdvertisement(); callback(); this.emit("paired" /* AccessoryEventTypes.PAIRED */); } handleAddPairing(connection, username, publicKey, permission, callback) { if (!this._accessoryInfo) { callback(6 /* TLVErrorCode.UNAVAILABLE */); return; } if (!this._accessoryInfo.hasAdminPermissions(connection.username)) { callback(2 /* TLVErrorCode.AUTHENTICATION */); return; } const existingKey = this._accessoryInfo.getClientPublicKey(username); if (existingKey) { if (existingKey.toStri