/* global NodeJS */ import assert from 'node:assert' import { Buffer } from 'node:buffer' import crypto from 'node:crypto' import { EventEmitter } from 'node:events' import net from 'node:net' import createDebug from 'debug' import { AccessoryJsonObject, CharacteristicId, CharacteristicsReadRequest, CharacteristicsWriteRequest, CharacteristicWrite, ConstructorArgs, HAPPincode, InterfaceName, IPAddress, MacAddress, Nullable, PartialCharacteristicReadData, PartialCharacteristicWriteData, ResourceRequest, ResourceRequestType, VoidCallback, WithUUID, } from '../types.js' import { Advertiser, AdvertiserEvent, AvahiAdvertiser, BonjourHAPAdvertiser, CiaoAdvertiser, ResolvedAdvertiser } from './Advertiser.js' // noinspection JSDeprecatedSymbols import { Access, ChangeReason, Characteristic, CharacteristicOperationContext, describePerms, isValidPerms, Perms, } from './Characteristic.js' import { CameraController, Controller, ControllerConstructor, ControllerIdentifier, ControllerServiceMap, isSerializableController, } from './controller/index.js' import { AccessoriesCallback, AddPairingCallback, HAPHTTPCode, HAPHttpError, HAPServer, HAPServerEventTypes, HAPStatus, IdentifyCallback, ListPairingsCallback, PairCallback, ReadCharacteristicsCallback, RemovePairingCallback, ResourceRequestCallback, TLVErrorCode, WriteCharacteristicsCallback, } from './HAPServer.js' import { AccessoryInfo, PermissionTypes } from './model/AccessoryInfo.js' import { ControllerStorage } from './model/ControllerStorage.js' import { IdentifierCache } from './model/IdentifierCache.js' import { SerializedService, Service, ServiceCharacteristicChange, ServiceEventTypes, ServiceId } from './Service.js' import { checkName } from './util/checkName.js' import { clone } from './util/clone.js' import { EventName, HAPConnection, HAPUsername } from './util/eventedhttp.js' import { HapStatusError } from './util/hapStatusError.js' import { once } from './util/once.js' import { extractHAPStatusFromError, formatOutgoingCharacteristicValue } from './util/request-util.js' import * as uuid from './util/uuid.js' import { toShortForm } from './util/uuid.js' const debug = createDebug('HAP-NodeJS:Accessory') const hksvDebug = createDebug('HAP-NodeJS:HKSV') 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 */ // eslint-disable-next-line no-restricted-syntax export const enum Categories { // noinspection JSUnusedGlobalSymbols OTHER = 1, BRIDGE = 2, FAN = 3, GARAGE_DOOR_OPENER = 4, LIGHTBULB = 5, DOOR_LOCK = 6, OUTLET = 7, SWITCH = 8, THERMOSTAT = 9, SENSOR = 10, ALARM_SYSTEM = 11, // eslint-disable-next-line ts/no-duplicate-enum-values SECURITY_SYSTEM = 11, // Added to conform to HAP naming DOOR = 12, WINDOW = 13, WINDOW_COVERING = 14, PROGRAMMABLE_SWITCH = 15, RANGE_EXTENDER = 16, CAMERA = 17, // eslint-disable-next-line ts/no-duplicate-enum-values IP_CAMERA = 17, // Added to conform to HAP naming VIDEO_DOORBELL = 18, AIR_PURIFIER = 19, AIR_HEATER = 20, AIR_CONDITIONER = 21, AIR_HUMIDIFIER = 22, AIR_DEHUMIDIFIER = 23, APPLE_TV = 24, HOMEPOD = 25, SPEAKER = 26, AIRPORT = 27, SPRINKLER = 28, FAUCET = 29, SHOWER_HEAD = 30, TELEVISION = 31, TARGET_CONTROLLER = 32, // Remote Control ROUTER = 33, AUDIO_RECEIVER = 34, TV_SET_TOP_BOX = 35, TV_STREAMING_STICK = 36, } /** * @group Accessory */ export interface SerializedAccessory { displayName: string UUID: string lastKnownUsername?: MacAddress category: Categories services: SerializedService[] linkedServices?: Record controllers?: SerializedControllerContext[] } /** * @group Controller API */ export interface SerializedControllerContext { type: ControllerIdentifier // this field is called type out of history services: SerializedServiceMap } /** * @group Controller API */ export type SerializedServiceMap = Record // maps controller defined name (from the ControllerServiceMap) to serviceId /** * @group Controller API */ export interface ControllerContext { controller: Controller serviceMap: ControllerServiceMap } /** * @group Accessory */ // eslint-disable-next-line no-restricted-syntax export const enum CharacteristicWarningType { SLOW_WRITE = 'slow-write', TIMEOUT_WRITE = 'timeout-write', SLOW_READ = 'slow-read', TIMEOUT_READ = 'timeout-read', WARN_MESSAGE = 'warn-message', ERROR_MESSAGE = 'error-message', DEBUG_MESSAGE = 'debug-message', } /** * @group Accessory */ export interface CharacteristicWarning { characteristic: Characteristic type: CharacteristicWarningType message: string originatorChain: string[] stack?: string } /** * @group Accessory */ export interface PublishInfo { username: MacAddress pincode: HAPPincode /** * Specify the category for the HomeKit accessory. * The category is used only in the mdns advertisement and specifies the devices type * for the HomeKit controller. * Currently, this only affects the icon shown in the pairing screen. * For the Television and Smart Speaker service it also affects the icon shown in * the Home app when paired. */ category?: Categories setupID?: string /** * Defines the host where the HAP server will be bound to. * When undefined the HAP server will bind to all available interfaces * (see https://nodejs.org/api/net.html#net_server_listen_port_host_backlog_callback). * * This property accepts a mixture of IPAddresses and network interface names. * Depending on the mixture of supplied addresses/names hap-nodejs will bind differently. * * It is advised to not just bind to a specific address, but specifying the interface name * in oder to bind on all address records (and ip version) available. * * HAP-NodeJS (or the underlying ciao library) will not report about misspelled interface names, * as it could be that the interface is currently just down and will come up later. * * Here are a few examples: * - bind: "::" * Pretty much identical to not specifying anything, as most systems (with ipv6 support) * will default to the unspecified ipv6 address (with dual stack support). * * - bind: "0.0.0.0" * Binding TCP socket to the unspecified ipv4 address. * The mdns advertisement will exclude any ipv6 address records. * * - bind: ["en0", "lo0"] * The mdns advertising will advertise all records of the en0 and loopback interface (if available) and * will also react to address changes on those interfaces. * In order for the HAP server to accept all those address records (which may contain ipv6 records) * it will bind on the unspecified ipv6 address "::" (assuming dual stack is supported). * * - bind: ["en0", "lo0", "0.0.0.0"] * Same as above, only that the HAP server will bind on the unspecified ipv4 address "0.0.0.0". * The mdns advertisement will not advertise any ipv6 records. * * - bind: "169.254.104.90" * This will bind the HAP server to the address 0.0.0.0. * The mdns advertisement will only advertise the A record 169.254.104.90. * If the given network interface of that address encounters an ip address change (to a different address), * the mdns advertisement will result in not advertising an address at all. * So it is advised to specify an interface name instead of a specific address. * This is identical with ipv6 addresses. * * - bind: ["169.254.104.90", "192.168.1.4"] * As the HAP TCP socket can only bind to a single address, when specifying multiple ip addresses * the HAP server will bind to the unspecified ip address (0.0.0.0 if only ipv4 addresses are supplied, * :: if a mixture or only ipv6 addresses are supplied). * The mdns advertisement will only advertise the specified ip addresses. * If the given network interface of that address encounters an ip address change (to different addresses), * the mdns advertisement will result in not advertising an address at all. * So it is advised to specify an interface name instead of a specific address. * */ bind?: (InterfaceName | IPAddress) | (InterfaceName | IPAddress)[] /** * Defines the port where the HAP server will be bound to. * When undefined port 0 will be used resulting in a random port. */ port?: number /** * If this option is set to true, HAP-NodeJS will add identifying material (based on {@link username}) * to the end of the accessory display name (and bonjour instance name). * Default: true */ addIdentifyingMaterial?: boolean /** * Defines the advertiser used with the published Accessory. */ advertiser?: MDNSAdvertiser } /** * @group Accessory */ // eslint-disable-next-line no-restricted-syntax export const enum MDNSAdvertiser { /** * Use the `@homebridge/ciao` module as advertiser. */ CIAO = 'ciao', /** * Use the `bonjour-hap` module as advertiser. */ BONJOUR = 'bonjour-hap', /** * Use Avahi/D-Bus as advertiser. */ 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. */ RESOLVED = 'resolved', } /** * @group Accessory */ export type AccessoryCharacteristicChange = ServiceCharacteristicChange & { service: Service } /** * @group Service */ export interface ServiceConfigurationChange { service: Service } // eslint-disable-next-line no-restricted-syntax const enum WriteRequestState { REGULAR_REQUEST, TIMED_WRITE_AUTHENTICATED, TIMED_WRITE_REJECTED, } /** * The first characteristic on an accessory whose permissions HomeKit would reject, together with the service holding it. The service travels alongside * the characteristic because a characteristic does not know its service, and the message pointing the plugin author at the fault needs both. */ interface PermsViolation { characteristic: Characteristic service: Service } /** * @group Accessory */ // eslint-disable-next-line no-restricted-syntax export const enum 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, and wrote `true` to the `Identify` Characteristic * of the `AccessoryInformation` service (after any handlers a plugin set up on that characteristic were called). * You must call the callback for identification to be successful. */ 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. */ 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}. */ ADVERTISED = 'advertised', SERVICE_CONFIGURATION_CHANGE = 'service-configurationChange', /** * Emitted after a change in the value of one of the provided Service's Characteristics. */ SERVICE_CHARACTERISTIC_CHANGE = 'service-characteristic-change', PAIRED = 'paired', UNPAIRED = 'unpaired', CHARACTERISTIC_WARNING = 'characteristic-warning', } // eslint-disable-next-line ts/no-unsafe-declaration-merging export declare interface Accessory { on(event: 'identify', listener: (paired: boolean, callback: VoidCallback) => void): this on(event: 'listening', listener: (port: number, address: string) => void): this on(event: 'advertised', listener: () => void): this on(event: 'service-configurationChange', listener: (change: ServiceConfigurationChange) => void): this on(event: 'service-characteristic-change', listener: (change: AccessoryCharacteristicChange) => void): this on(event: 'paired', listener: () => void): this on(event: 'unpaired', listener: () => void): this on(event: 'characteristic-warning', listener: (warning: CharacteristicWarning) => void): this emit(event: 'identify', paired: boolean, callback: VoidCallback): boolean emit(event: 'listening', port: number, address: string): boolean emit(event: 'advertised'): boolean emit(event: 'service-configurationChange', change: ServiceConfigurationChange): boolean emit(event: 'service-characteristic-change', change: AccessoryCharacteristicChange): boolean emit(event: 'paired'): boolean emit(event: 'unpaired'): boolean emit(event: 'characteristic-warning', warning: CharacteristicWarning): boolean } /** * 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 ts/no-unsafe-declaration-merging export class Accessory extends EventEmitter { // Timeout in milliseconds until a characteristic warning is issue private static readonly TIMEOUT_WARNING = 3000 // Timeout in milliseconds after `TIMEOUT_WARNING` until the operation on the characteristic is considered timed out. private static readonly TIMEOUT_AFTER_WARNING = 6000 // NOTICE: when adding/changing properties, remember to possibly adjust the serialize/deserialize functions aid: Nullable = 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?: Accessory // if accessory is bridged, this property points to the bridge which bridges this accessory bridgedAccessories: Accessory[] = [] // If we are a Bridge, these are the Accessories we are bridging reachable = true lastKnownUsername?: MacAddress category: Categories = Categories.OTHER services: Service[] = [] private primaryService?: Service 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 initialized = false private controllers: Record = {} private serializedControllers?: Record // store uninitialized controller data after a Accessory.deserialize call private activeCameraController?: CameraController /** * @private */ _accessoryInfo?: Nullable /** * @private */ _setupID: Nullable = null /** * @private */ _identifierCache?: Nullable /** * @private */ controllerStorage!: ControllerStorage /** * @private */ _advertiser?: Advertiser /** * @private */ _server?: HAPServer /** * @private */ _setupURI?: string private configurationChangeDebounceTimeout?: NodeJS.Timeout /** * 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 */ private lastAccessoriesRequest = 0 /** * The result of the last permissions evaluation made by the bridge that hosts this accessory, used solely to tell a transition in or out of quarantine * from a state that has not changed. It is never consulted to decide what gets served: {@link findPermsViolation}, re-run on every representation build, * is the truth there. */ private permsQuarantined = false constructor(public displayName: string, public UUID: string) { super() assert(displayName, 'Accessories must be created with a non-empty displayName.') assert(UUID, 'Accessories must be created with a valid UUID.') assert(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.') this.controllerStorage = new ControllerStorage(this) // create our initial "Accessory Information" Service that all Accessories are expected to have checkName(this.displayName, 'Name', displayName) this.addService(Service.AccessoryInformation) .setCharacteristic(Characteristic.Name, displayName) } private identificationRequest(paired: boolean, callback: IdentifyCallback) { debug('[%s] Identification request', this.displayName) if (this.listeners(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(AccessoryEventTypes.IDENTIFY, paired, callback) } else { debug('[%s] Identification request ignored; no listeners to \'identify\' event', this.displayName) callback() } } /** * A paired device wishes for us to identify ourselves by writing `true` to the `Identify` characteristic (as opposed * to an unpaired device - that case is handled by the HAPServer 'identify' event). This is handled here rather than * by a `set` listener on the characteristic, so a plugin's own handlers on it don't compete with ours. */ private isIdentifyCharacteristic(characteristic: Characteristic): boolean { return characteristic.UUID === Characteristic.Identify.UUID && !!this.getService(Service.AccessoryInformation)?.characteristics.includes(characteristic) } /** * @returns a promise resolving once the identification is done, rejecting with the {@link HAPStatus} to answer otherwise. */ private handlePairedIdentify(characteristic: Characteristic): Promise { return new Promise((resolve, reject) => { try { this.identificationRequest(true, once((error?: Nullable) => { if (!error) { resolve() } else if (typeof error === 'number') { reject(new HapStatusError(error).hapStatus) } else if (error instanceof HapStatusError) { reject(error.hapStatus) } else { debug('[%s] Received error from identify handler %s', this.displayName, error.stack) reject(extractHAPStatusFromError(error)) } })) } catch (error) { this.sendCharacteristicWarning(characteristic, CharacteristicWarningType.ERROR_MESSAGE, `Unhandled error thrown inside identify handler: ${error?.message}`) reject(HAPStatus.SERVICE_COMMUNICATION_FAILURE) } }) } /** * Add the given service instance to the Accessory. * * @param service - A {@link Service} instance. * @returns Returns the service instance passed to the method call. */ public addService(service: Service): Service /** * Adds a given service by calling the provided {@link Service} constructor with the provided constructor arguments. * @param serviceConstructor - A {@link Service} service constructor (e.g. {@link Service.Switch}). * @param constructorArgs - The arguments passed to the given constructor. * @returns Returns the constructed service instance. */ public addService(serviceConstructor: S, ...constructorArgs: ConstructorArgs): Service public addService(serviceParam: Service | typeof Service, ...constructorArgs: any[]): Service { // service might be a constructor like `Service.AccessoryInformation` instead of an instance // of Service. Coerce if necessary. const service: 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(AccessoryEventTypes.SERVICE_CONFIGURATION_CHANGE, { service }) } this.setupServiceEventHandlers(service) return service } public removeService(service: Service): void { 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(AccessoryEventTypes.SERVICE_CONFIGURATION_CHANGE, { service }) } service.removeAllListeners() } } private removeLinkedService(removed: Service) { for (const service of this.services) { service.removeLinkedService(removed) } } public getService>(name: string | T): Service | undefined { 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 } public getServiceById>(uuid: string | T, subType: string): Service | undefined { 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 */ public getPrimaryAccessory = (): Accessory => { return this.bridged ? this.bridge! : this } public addBridgedAccessory(accessory: Accessory, deferUpdate = false): Accessory { 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(AccessoryEventTypes.SERVICE_CHARACTERISTIC_CHANGE, change => this.handleCharacteristicChangeEvent(accessory, change.service, change)) accessory.on(AccessoryEventTypes.SERVICE_CONFIGURATION_CHANGE, this.enqueueConfigurationUpdate.bind(this)) accessory.on(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 } public addBridgedAccessories(accessories: Accessory[]): void { for (const accessory of accessories) { this.addBridgedAccessory(accessory, true) } this.enqueueConfigurationUpdate() } public removeBridgedAccessory(accessory: Accessory, deferUpdate = false): void { // 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) if (this._server && accessory.aid != null) { // connected clients stay subscribed to the removed accessory's ids, which a replacement under the same UUID gets again for (const iid of this._server.disableEventNotificationsForAccessory(accessory.aid)) { accessory.getCharacteristicByIID(iid)?.unsubscribe() } } accessory.bridged = false accessory.bridge = undefined accessory.permsQuarantined = false // a later bridge starts from a clean slate and re-evaluates the accessory itself accessory.removeAllListeners() this.controllerStorage.unlinkAccessory(accessory) if (!deferUpdate) { this.enqueueConfigurationUpdate() } } public removeBridgedAccessories(accessories: Accessory[]): void { for (const accessory of accessories) { this.removeBridgedAccessory(accessory, true) } this.enqueueConfigurationUpdate() } public removeAllBridgedAccessories(): void { for (let i = this.bridgedAccessories.length - 1; i >= 0; i--) { this.removeBridgedAccessory(this.bridgedAccessories[i], true) } this.enqueueConfigurationUpdate() } private getCharacteristicByIID(iid: number): Characteristic | undefined { for (const service of this.services) { const characteristic = service.getCharacteristicByIID(iid) if (characteristic) { return characteristic } } } protected getAccessoryByAID(aid: number): Accessory | undefined { if (this.aid === aid) { return this } return this.bridgedAccessories.find(value => value.aid === aid) } protected findCharacteristic(aid: number, iid: number): Characteristic | undefined { 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. */ public configureController(controllerConstructor: Controller | ControllerConstructor): void { 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: ControllerServiceMap if (savedServiceMap) { // we found data to restore from const clonedServiceMap = 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: ControllerContext = { controller, serviceMap, } if (isSerializableController(controller)) { this.controllerStorage.trackController(controller) } this.controllers[id] = context if (controller instanceof 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. */ public removeController(controller: Controller): void { 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 (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] } } private handleAccessoryUnpairedForControllers(): void { 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 (isSerializableController(controller)) { this.controllerStorage.purgeControllerData(controller) } } } private handleUpdatedControllerServiceMap(originalServiceMap: ControllerServiceMap, updatedServiceMap: ControllerServiceMap) { updatedServiceMap = clone(updatedServiceMap) // clone it so we can alter it Object.keys(originalServiceMap).forEach((name) => { // this loop removed any services contained in both ServiceMaps 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(): string { if (this._setupURI) { return this._setupURI } assert(!!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 } /** * Searches this accessory for the first characteristic whose permissions HomeKit would reject, ignoring any bridged accessories (each one is asked * about itself). The permissions are read from the model on every call rather than remembered, because a plugin can assign `props.perms` directly at * any time, which reaches neither {@link Characteristic.setProps} nor any event. * * @returns the offending characteristic and its service, or undefined if every characteristic on this accessory is well-formed. */ private findPermsViolation(): PermsViolation | undefined { for (const service of this.services) { for (const characteristic of service.characteristics) { if (!isValidPerms(characteristic.props.perms)) { return { characteristic, service } } } } return undefined } /** * Describes a permissions violation to the plugin author: which accessory, which service, which characteristic, and the array that was rejected. The * publish failure and the quarantine warning report the same fault at different boundaries, so they share this text and supply only the phrase that * says what happened as a result. * * @param violation - the violation as returned by {@link findPermsViolation}. * @param disposition - what became of this accessory, phrased to follow its name. */ private permsViolationMessage(violation: PermsViolation, disposition: string): string { const serviceName = violation.service.displayName || violation.service.constructor.name return `HAP-NodeJS: accessory '${this.displayName}' ${disposition}. Service '${serviceName}' (${violation.service.UUID}), ` + `characteristic '${violation.characteristic.displayName}' (${violation.characteristic.UUID}) contains invalid permissions: ` + `${describePerms(violation.characteristic.props.perms)}` } /** * Returns the bridged accessories fit to be served, excluding any whose characteristic permissions HomeKit would reject. Both representation builders * go through here, so the accessory database we hand out and the configuration we hash agree by construction, whatever put the malformed permissions * into the model. * * The exclusion is presentation-level and reversible. A quarantined accessory keeps its place in {@link bridgedAccessories}, its aid, its cached * identifiers and its context, so correcting the permissions restores it exactly where it was, under the same identity HomeKit knew it by. */ private servedBridgedAccessories(): Accessory[] { const served: Accessory[] = [] for (const accessory of this.bridgedAccessories) { const violation = accessory.findPermsViolation() if (violation) { if (!accessory.permsQuarantined) { accessory.permsQuarantined = true accessory.sendCharacteristicWarning( violation.characteristic, CharacteristicWarningType.ERROR_MESSAGE, accessory.permsViolationMessage(violation, 'is excluded from the accessory database until its permissions are corrected'), ) /* The configuration this accessory contributed has just disappeared from what we serve, so the configuration number has to move. Enqueueing * from inside a representation build is safe and terminates: the build that observes a transition records it before returning, so the * debounced pass that follows sees a settled state, hashes it, and enqueues nothing further. */ this.enqueueConfigurationUpdate() } continue } if (accessory.permsQuarantined) { accessory.permsQuarantined = false this.enqueueConfigurationUpdate() } served.push(accessory) } return served } /** * 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. */ private validateAccessory(mainAccessory?: boolean) { const service = this.getService(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 { const checkValue = (name: string, value?: any) => { 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!') } } checkName(this.displayName, 'Name', service.getCharacteristic(Characteristic.Name).value) checkValue('FirmwareRevision', service.getCharacteristic(Characteristic.FirmwareRevision).value) checkValue('Manufacturer', service.getCharacteristic(Characteristic.Manufacturer).value) checkValue('Model', service.getCharacteristic(Characteristic.Model).value) checkValue('Name', service.getCharacteristic(Characteristic.Name).value) checkValue('SerialNumber', service.getCharacteristic(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) assert(Buffer.from(this.displayName, 'utf8').length <= 63, 'Accessory displayName cannot be longer than 63 bytes!') } // any accessories we are bridging are validated the same way; the list is empty for anything that is not a bridge 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 */ _assignIDs(identifierCache: IdentifierCache): void { // 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(): void { this.shouldPurgeUnusedIDs = false } enableUnusedIDPurge(): void { 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(): void { // 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. */ private async toHAP(connection: HAPConnection, contactGetHandlers = true): Promise { assert(this.aid, `aid cannot be undefined for accessory '${this.displayName}'`) assert(this.services.length, `accessory '${this.displayName}' does not have any services!`) const accessory: AccessoryJsonObject = { aid: this.aid!, services: await Promise.all(this.services.map(service => service.toHAP(connection, contactGetHandlers))), } const accessories: AccessoryJsonObject[] = [accessory] if (!this.bridged) { accessories.push(...await Promise.all( this.servedBridgedAccessories() .map(accessory => accessory.toHAP(connection, contactGetHandlers).then(value => value[0])), )) } return accessories } /** * Returns a JSON representation of this accessory without characteristic values. */ private internalHAPRepresentation(assignIds = true): AccessoryJsonObject[] { if (assignIds) { this._assignIDs(this._identifierCache!) // make sure our aid/iid's are all assigned } assert(this.aid, `aid cannot be undefined for accessory '${this.displayName}'`) assert(this.services.length, `accessory '${this.displayName}' does not have any services!`) const accessory: AccessoryJsonObject = { aid: this.aid!, services: this.services.map(service => service.internalHAPRepresentation()), } const accessories: AccessoryJsonObject[] = [accessory] if (!this.bridged) { for (const accessory of this.servedBridgedAccessories()) { 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 */ public async publish(info: PublishInfo, allowInsecureRequest?: boolean): Promise { if (this.bridged) { throw new Error(`Can't publish in accessory which is bridged by another accessory. Bridged by ${this.bridge?.displayName}`) } /* Bridged accessories with malformed permissions are quarantined at the serving boundary, but the accessory being published is the root of its own * accessory database and has nowhere to be quarantined to. HomeKit would reject the whole database, so we refuse here instead, in the caller's stack. */ const violation = this.findPermsViolation() if (violation) { throw new Error(this.permsViolationMessage(violation, 'cannot be published')) } let service = this.getService(Service.ProtocolInformation) if (!service) { service = this.addService(Service.ProtocolInformation) // add the protocol information service to the primary accessory } service.setCharacteristic(Characteristic.Version, CiaoAdvertiser.protocolVersionService) hksvDebug('[%s] publish: UUID=%s lastKnownUsername=%s newUsername=%s', this.displayName, this.UUID, this.lastKnownUsername, info.username) if (this.lastKnownUsername && this.lastKnownUsername !== info.username) { // username changed since last publish hksvDebug('[%s] publish: wiping data for previous username %s', this.displayName, this.lastKnownUsername) 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.createHash('sha512') .update(info.username, 'utf8') .digest('hex') .slice(0, 4) .toUpperCase()}` this.getService(Service.AccessoryInformation)!.updateCharacteristic(Characteristic.Name, this.displayName) } // attempt to load existing AccessoryInfo from disk this._accessoryInfo = 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.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.AccessoryInformation)!.getCharacteristic(Characteristic.Model).value as string this._accessoryInfo.category = info.category || 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.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(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) info.advertiser ??= MDNSAdvertiser.CIAO if ( (info.advertiser === MDNSAdvertiser.AVAHI && !await AvahiAdvertiser.isAvailable()) || (info.advertiser === MDNSAdvertiser.RESOLVED && !await ResolvedAdvertiser.isAvailable()) ) { console.error( `[${this.displayName}] The selected advertiser, "${info.advertiser}", isn't available on this platform. ` + `Reverting to "${MDNSAdvertiser.CIAO}"`, ) info.advertiser = MDNSAdvertiser.CIAO } switch (info.advertiser) { case MDNSAdvertiser.CIAO: this._advertiser = new CiaoAdvertiser(this._accessoryInfo, { interface: parsed.advertiserAddress, }, { restrictedAddresses: parsed.serviceRestrictedAddress, disabledIpv6: parsed.serviceDisableIpv6, }) break case MDNSAdvertiser.BONJOUR: this._advertiser = new BonjourHAPAdvertiser(this._accessoryInfo, { restrictedAddresses: parsed.serviceRestrictedAddress, disabledIpv6: parsed.serviceDisableIpv6, }) break case MDNSAdvertiser.AVAHI: this._advertiser = new AvahiAdvertiser(this._accessoryInfo) break case MDNSAdvertiser.RESOLVED: this._advertiser = new ResolvedAdvertiser(this._accessoryInfo) break default: throw new Error(`Unsupported advertiser setting: '${info.advertiser}'`) } this._advertiser.on(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.AccessoryInformation)! .updateCharacteristic(Characteristic.Name, name) }) // create our HAP server which handles all communication between iOS devices and us this._server = new HAPServer(this._accessoryInfo) this._server.allowInsecureRequest = !!allowInsecureRequest this._server.on(HAPServerEventTypes.LISTENING, this.onListening.bind(this)) this._server.on(HAPServerEventTypes.IDENTIFY, this.identificationRequest.bind(this, false)) this._server.on(HAPServerEventTypes.PAIR, this.handleInitialPairSetupFinished.bind(this)) this._server.on(HAPServerEventTypes.ADD_PAIRING, this.handleAddPairing.bind(this)) this._server.on(HAPServerEventTypes.REMOVE_PAIRING, this.handleRemovePairing.bind(this)) this._server.on(HAPServerEventTypes.LIST_PAIRINGS, this.handleListPairings.bind(this)) this._server.on(HAPServerEventTypes.ACCESSORIES, this.handleAccessories.bind(this)) this._server.on(HAPServerEventTypes.GET_CHARACTERISTICS, this.handleGetCharacteristics.bind(this)) this._server.on(HAPServerEventTypes.SET_CHARACTERISTICS, this.handleSetCharacteristics.bind(this)) this._server.on(HAPServerEventTypes.CONNECTION_CLOSED, this.handleHAPConnectionClosed.bind(this)) this._server.on(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 */ public destroy(): Promise { const promise = this.unpublish() if (this._accessoryInfo) { Accessory.cleanupAccessoryData(this._accessoryInfo.username) this._accessoryInfo = undefined this._identifierCache = undefined this.controllerStorage = new ControllerStorage(this) } this.removeAllListeners() return promise } public async unpublish(): Promise { if (this._server) { this._server.destroy() this._server = undefined } if (this._advertiser) { // noinspection JSIgnoredPromiseFromCall await this._advertiser.destroy() this._advertiser = undefined } } private enqueueConfigurationUpdate(): void { if (this.configurationChangeDebounceTimeout) { return // already enqueued } this.configurationChangeDebounceTimeout = setTimeout(() => { this.configurationChangeDebounceTimeout = undefined if (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 } private onListening(port: number, hostname: string): void { assert(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(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(AccessoryEventTypes.LISTENING, port, hostname) } private handleInitialPairSetupFinished(username: string, publicKey: Buffer, callback: PairCallback): void { debug('[%s] Paired with client %s', this.displayName, username) if (this._accessoryInfo) { this._accessoryInfo.addPairedClient(username, publicKey, PermissionTypes.ADMIN) this._accessoryInfo.save() } // update our advertisement, so it can pick up on the paired status of AccessoryInfo if (this._advertiser) { this._advertiser.updateAdvertisement() } callback() this.emit(AccessoryEventTypes.PAIRED) } private handleAddPairing(connection: HAPConnection, username: string, publicKey: Buffer, permission: PermissionTypes, callback: AddPairingCallback): void { if (!this._accessoryInfo) { callback(TLVErrorCode.UNAVAILABLE) return } if (!this._accessoryInfo.hasAdminPermissions(connection.username!)) { callback(TLVErrorCode.AUTHENTICATION) return } const existingKey = this._accessoryInfo.getClientPublicKey(username) if (existingKey) { if (!existingKey.equals(publicKey)) { callback(TLVErrorCode.UNKNOWN) return } this._accessoryInfo.updatePermission(username, permission) } else { this._accessoryInfo.addPairedClient(username, publicKey, permission) } this._accessoryInfo.save() // there should be no need to update advertisement callback(0) } private handleRemovePairing(connection: HAPConnection, username: HAPUsername, callback: RemovePairingCallback): void { if (!this._accessoryInfo) { callback(TLVErrorCode.UNAVAILABLE) return } if (!this._accessoryInfo.hasAdminPermissions(connection.username!)) { callback(TLVErrorCode.AUTHENTICATION) return } this._accessoryInfo.removePairedClient(connection, username) this._accessoryInfo.save() callback(0) // first of all ensure the pairing is removed before we advertise availability again if (!this._accessoryInfo.paired()) { if (this._advertiser) { this._advertiser.updateAdvertisement() } this.emit(AccessoryEventTypes.UNPAIRED) this.handleAccessoryUnpairedForControllers() for (const accessory of this.bridgedAccessories) { accessory.handleAccessoryUnpairedForControllers() } } } private handleListPairings(connection: HAPConnection, callback: ListPairingsCallback): void { if (!this._accessoryInfo) { callback(TLVErrorCode.UNAVAILABLE) return } if (!this._accessoryInfo.hasAdminPermissions(connection.username!)) { callback(TLVErrorCode.AUTHENTICATION) return } callback(0, this._accessoryInfo.listPairings()) } private handleAccessories(connection: HAPConnection, callback: AccessoriesCallback): void { this._assignIDs(this._identifierCache!) // make sure our aid/iid's are all assigned const now = Date.now() const contactGetHandlers = now - this.lastAccessoriesRequest > 5_000 // we query the latest value if last /accessories was more than 5s ago this.lastAccessoriesRequest = now this.toHAP(connection, contactGetHandlers).then((value) => { callback(undefined, { accessories: value, }) }, (reason) => { console.error(`[${this.displayName}] /accessories request error with: ${reason.stack}`) callback({ httpCode: HAPHTTPCode.INTERNAL_SERVER_ERROR, status: HAPStatus.SERVICE_COMMUNICATION_FAILURE }) }) } private handleGetCharacteristics(connection: HAPConnection, request: CharacteristicsReadRequest, callback: ReadCharacteristicsCallback): void { this.handleCharacteristicRequests( request.ids, 'read', id => this.handleCharacteristicRead(connection, id, request), (error, characteristics) => callback(error, characteristics && { characteristics }), ) } /** * Runs the handler for every characteristic of a `/characteristics` read or write request, and answers once all of them settled. * A handler still running after {@link TIMEOUT_WARNING} is reported as slow. Once {@link TIMEOUT_AFTER_WARNING} more passed, the * request is answered without waiting any longer: every characteristic still missing is reported as timed out and answered with * {@link HAPStatus.OPERATION_TIMED_OUT}. * * @param ids - The requested characteristics. * @param operation - Whether this is a read or a write, for the warnings and the log. * @param handle - Handles a single requested characteristic. * @param respond - Called exactly once, with the results in the order they settled, or with the error answering the whole request. */ private handleCharacteristicRequests( ids: Id[], operation: 'read' | 'write', handle: (id: Id) => Promise, respond: (error: HAPHttpError | undefined, characteristics?: (Result & CharacteristicId)[]) => void, ): void { // both result types have a member which carries nothing but the status const statusOnly = (status: HAPStatus) => ({ status }) as Result const characteristics: (Result & CharacteristicId)[] = [] const missingCharacteristics: Map = new Map(ids.map(id => [`${id.aid}.${id.iid}`, id])) if (missingCharacteristics.size !== ids.length) { // if those sizes differ, we have duplicates and can't properly handle that respond({ httpCode: HAPHTTPCode.UNPROCESSABLE_ENTITY, status: HAPStatus.INVALID_VALUE_IN_REQUEST }) return } if (ids.length === 0) { respond(undefined, characteristics) // nothing to wait for return } const warnMissing = (slow: boolean) => { for (const id of missingCharacteristics.values()) { const accessory = this.getAccessoryByAID(id.aid) const characteristic = accessory?.getCharacteristicByIID(id.iid) if (!accessory || !characteristic) { continue } const type = operation === 'read' ? (slow ? CharacteristicWarningType.SLOW_READ : CharacteristicWarningType.TIMEOUT_READ) : (slow ? CharacteristicWarningType.SLOW_WRITE : CharacteristicWarningType.TIMEOUT_WRITE) const message = slow ? 'was slow to respond!' : 'didn\'t respond at all!. Please check that you properly call the callback!' accessory.sendCharacteristicWarning(characteristic, type, `The ${operation} handler for the characteristic '${ characteristic.displayName}' on the accessory '${accessory.displayName}' ${message}`) if (!slow) { characteristics.push({ aid: id.aid, iid: id.iid, ...statusOnly(HAPStatus.OPERATION_TIMED_OUT), }) } } } let timeout: NodeJS.Timeout | undefined = setTimeout(() => { warnMissing(true) // after a total of 10s we do no longer wait for a request to appear and just return status code timeout timeout = setTimeout(() => { timeout = undefined warnMissing(false) missingCharacteristics.clear() respond(undefined, characteristics) }, Accessory.TIMEOUT_AFTER_WARNING) timeout.unref() }, Accessory.TIMEOUT_WARNING) timeout.unref() for (const [name, id] of missingCharacteristics) { handle(id).then((value) => { return { aid: id.aid, iid: id.iid, ...value, } }, (reason) => { // this error block is only called if hap-nodejs itself messed up console.error(`[${this.displayName}] ${operation === 'read' ? 'Read' : 'Write'} request for characteristic ${name} encountered an error: ${ reason.stack}`) return { aid: id.aid, iid: id.iid, ...statusOnly(HAPStatus.SERVICE_COMMUNICATION_FAILURE), } }).then((value) => { if (!timeout) { return // if timeout is undefined, response was already sent out } missingCharacteristics.delete(name) characteristics.push(value) if (missingCharacteristics.size === 0) { // if everything returned send the response clearTimeout(timeout) timeout = undefined respond(undefined, characteristics) } }) } } private async handleCharacteristicRead( connection: HAPConnection, id: CharacteristicId, request: CharacteristicsReadRequest, ): Promise { const characteristic = this.findCharacteristic(id.aid, id.iid) if (!characteristic) { debug('[%s] Could not find a Characteristic with aid of %s and iid of %s', this.displayName, id.aid, id.iid) return { status: HAPStatus.INVALID_VALUE_IN_REQUEST } } if (!characteristic.props.perms.includes(Perms.PAIRED_READ)) { // check if read is allowed for this characteristic debug('[%s] Tried reading from characteristic which does not allow reading (aid of %s and iid of %s)', this.displayName, id.aid, id.iid) return { status: HAPStatus.WRITE_ONLY_CHARACTERISTIC } } if (characteristic.props.adminOnlyAccess && characteristic.props.adminOnlyAccess.includes(Access.READ)) { const verifiable = this._accessoryInfo && connection.username if (!verifiable) { debug('[%s] Could not verify admin permissions for Characteristic which requires admin permissions for reading (aid of %s and iid of %s)', this.displayName, id.aid, id.iid) } if (!verifiable || !this._accessoryInfo!.hasAdminPermissions(connection.username!)) { return { status: HAPStatus.INSUFFICIENT_PRIVILEGES } } } return characteristic.handleGetRequest(connection).then((value) => { value = formatOutgoingCharacteristicValue(value, characteristic.props) debug('[%s] Got Characteristic "%s" value: "%s"', this.displayName, characteristic!.displayName, value) const data: PartialCharacteristicReadData = { value: value ?? null, } if (request.includeMeta) { data.format = characteristic.props.format data.unit = characteristic.props.unit data.minValue = characteristic.props.minValue data.maxValue = characteristic.props.maxValue data.minStep = characteristic.props.minStep data.maxLen = characteristic.props.maxLen || characteristic.props.maxDataLen } if (request.includePerms) { data.perms = characteristic.props.perms } if (request.includeType) { data.type = toShortForm(characteristic.UUID) } if (request.includeEvent) { data.ev = connection.hasEventNotifications(id.aid, id.iid) } return data }, (reason: HAPStatus) => { debug('[%s] Error getting value for characteristic "%s": %s', this.displayName, characteristic.displayName, HAPStatus[reason]) return { status: reason } }) } private handleSetCharacteristics(connection: HAPConnection, writeRequest: CharacteristicsWriteRequest, callback: WriteCharacteristicsCallback): void { if (debug.enabled) { // the request can be large, so only serialize it when it gets logged debug('[%s] Processing characteristic set: %s', this.displayName, JSON.stringify(writeRequest)) } let writeState: WriteRequestState = WriteRequestState.REGULAR_REQUEST if (writeRequest.pid !== undefined) { // check for timed writes if (connection.timedWritePid === writeRequest.pid) { writeState = WriteRequestState.TIMED_WRITE_AUTHENTICATED clearTimeout(connection.timedWriteTimeout!) connection.timedWritePid = undefined connection.timedWriteTimeout = undefined debug('[%s] Timed write request got acknowledged for pid %d', this.displayName, writeRequest.pid) } else { writeState = WriteRequestState.TIMED_WRITE_REJECTED debug('[%s] TTL for timed write request has probably expired for pid %d', this.displayName, writeRequest.pid) } } this.handleCharacteristicRequests( writeRequest.characteristics, 'write', data => this.handleCharacteristicWrite(connection, data, writeState), (error, characteristics) => callback(error, characteristics && { characteristics }), ) } private async handleCharacteristicWrite( connection: HAPConnection, data: CharacteristicWrite, writeState: WriteRequestState, ): Promise { const characteristic = this.findCharacteristic(data.aid, data.iid) if (!characteristic) { debug('[%s] Could not find a Characteristic with aid of %s and iid of %s', this.displayName, data.aid, data.iid) return { status: HAPStatus.INVALID_VALUE_IN_REQUEST } } if (writeState === WriteRequestState.TIMED_WRITE_REJECTED) { return { status: HAPStatus.INVALID_VALUE_IN_REQUEST } } if (data.ev == null && data.value == null) { return { status: HAPStatus.INVALID_VALUE_IN_REQUEST } } if (data.ev != null) { // register/unregister event notifications if (!characteristic.props.perms.includes(Perms.NOTIFY)) { // check if notify is allowed for this characteristic debug('[%s] Tried %s notifications for Characteristic which does not allow notify (aid of %s and iid of %s)', this.displayName, data.ev ? 'enabling' : 'disabling', data.aid, data.iid) return { status: HAPStatus.NOTIFICATION_NOT_SUPPORTED } } if (characteristic.props.adminOnlyAccess && characteristic.props.adminOnlyAccess.includes(Access.NOTIFY)) { const verifiable = connection.username && this._accessoryInfo if (!verifiable) { debug('[%s] Could not verify admin permissions for Characteristic which requires admin permissions for notify (aid of %s and iid of %s)', this.displayName, data.aid, data.iid) } if (!verifiable || !this._accessoryInfo!.hasAdminPermissions(connection.username!)) { return { status: HAPStatus.INSUFFICIENT_PRIVILEGES } } } const notificationsEnabled = connection.hasEventNotifications(data.aid, data.iid) if (data.ev && !notificationsEnabled) { connection.enableEventNotifications(data.aid, data.iid) characteristic.subscribe() debug('[%s] Registered Characteristic "%s" on "%s" for events', connection.remoteAddress, characteristic.displayName, this.displayName) } else if (!data.ev && notificationsEnabled) { characteristic.unsubscribe() connection.disableEventNotifications(data.aid, data.iid) debug('[%s] Unregistered Characteristic "%s" on "%s" for events', connection.remoteAddress, characteristic.displayName, this.displayName) } // response is returned below in the else block } if (data.value != null) { if (!characteristic.props.perms.includes(Perms.PAIRED_WRITE)) { // check if write is allowed for this characteristic debug('[%s] Tried writing to Characteristic which does not allow writing (aid of %s and iid of %s)', this.displayName, data.aid, data.iid) return { status: HAPStatus.READ_ONLY_CHARACTERISTIC } } if (characteristic.props.adminOnlyAccess && characteristic.props.adminOnlyAccess.includes(Access.WRITE)) { const verifiable = connection.username && this._accessoryInfo if (!verifiable) { debug('[%s] Could not verify admin permissions for Characteristic which requires admin permissions for write (aid of %s and iid of %s)', this.displayName, data.aid, data.iid) } if (!verifiable || !this._accessoryInfo!.hasAdminPermissions(connection.username!)) { return { status: HAPStatus.INSUFFICIENT_PRIVILEGES } } } if (characteristic.props.perms.includes(Perms.ADDITIONAL_AUTHORIZATION) && characteristic.additionalAuthorizationHandler) { // if the characteristic "supports additional authorization" but doesn't define a handler for the check // we conclude that the characteristic doesn't want to check the authData (currently) and just allows access for everybody let allowWrite try { allowWrite = characteristic.additionalAuthorizationHandler(data.authData) } catch (error) { console.warn(`[${this.displayName}] Additional authorization handler has thrown an error when checking authData: ${error.stack}`) allowWrite = false } if (!allowWrite) { return { status: HAPStatus.INSUFFICIENT_AUTHORIZATION } } } if (characteristic.props.perms.includes(Perms.TIMED_WRITE) && writeState !== WriteRequestState.TIMED_WRITE_AUTHENTICATED) { debug('[%s] Tried writing to a timed write only Characteristic without properly preparing (iid of %s and aid of %s)', this.displayName, data.aid, data.iid) return { status: HAPStatus.INVALID_VALUE_IN_REQUEST } } const accessory = this.getAccessoryByAID(data.aid)! return characteristic.handleSetRequest(data.value, connection).then(async (value) => { if (data.value && accessory.isIdentifyCharacteristic(characteristic)) { await accessory.handlePairedIdentify(characteristic) } return value }).then((value) => { debug('[%s] Setting Characteristic "%s" to value %s', this.displayName, characteristic.displayName, data.value) return { // if write response is requests and value is provided, return that value: data.r && value ? formatOutgoingCharacteristicValue(value, characteristic.props) : undefined, status: HAPStatus.SUCCESS, } }, (status: HAPStatus) => { debug('[%s] Error setting Characteristic "%s" to value %s: ', this.displayName, characteristic.displayName, data.value, HAPStatus[status]) return { status } }) } return { status: HAPStatus.SUCCESS } } private handleResource(data: ResourceRequest, callback: ResourceRequestCallback): void { if (data['resource-type'] === ResourceRequestType.IMAGE) { const aid = data.aid // aid is optionally supplied by HomeKit (for example when camera is bridged, multiple cams, etc) let accessory: Accessory | undefined let controller: CameraController | undefined if (aid) { accessory = this.getAccessoryByAID(aid) if (accessory && accessory.activeCameraController) { controller = accessory.activeCameraController } } else if (this.activeCameraController) { // aid was not supplied, check if this accessory is a camera // eslint-disable-next-line ts/no-this-alias accessory = this controller = this.activeCameraController } if (!controller) { debug('[%s] received snapshot request though no camera controller was associated!') callback({ httpCode: HAPHTTPCode.NOT_FOUND, status: HAPStatus.RESOURCE_DOES_NOT_EXIST }) return } controller.handleSnapshotRequest(data['image-height'], data['image-width'], accessory?.displayName, data.reason) .then((buffer) => { callback(undefined, buffer) }, (status: HAPStatus) => { callback({ httpCode: HAPHTTPCode.MULTI_STATUS, status }) }) return } debug(`[%s] received request for unsupported image type: ${data['resource-type']}`, this._accessoryInfo?.username) callback({ httpCode: HAPHTTPCode.NOT_FOUND, status: HAPStatus.RESOURCE_DOES_NOT_EXIST }) } private handleHAPConnectionClosed(connection: HAPConnection): void { for (const event of connection.getRegisteredEvents()) { const ids = event.split('.') if (ids.length < 2) { continue } const aid = parseInt(ids[0], 10) const iid = parseInt(ids[1], 10) const characteristic = this.findCharacteristic(aid, iid) if (characteristic) { characteristic.unsubscribe() } } connection.clearRegisteredEvents() } private handleServiceConfigurationChangeEvent(service: Service): void { if (!service.isPrimaryService && service === this.primaryService) { // service changed form primary to non-primary service this.primaryService = undefined } else if (service.isPrimaryService && service !== this.primaryService) { // service changed from non-primary to primary service if (this.primaryService !== undefined) { this.primaryService.isPrimaryService = false } this.primaryService = service } if (this.bridged) { this.emit(AccessoryEventTypes.SERVICE_CONFIGURATION_CHANGE, { service }) } else { this.enqueueConfigurationUpdate() } } private handleCharacteristicChangeEvent(accessory: Accessory, service: Service, change: ServiceCharacteristicChange): void { if (this.bridged) { // forward this to our main accessory this.emit(AccessoryEventTypes.SERVICE_CHARACTERISTIC_CHANGE, { ...change, service }) } else { if (!this._server) { return // we're not running a HAPServer, so there's no one to notify about this event } if (accessory.aid == null || change.characteristic.iid == null) { debug('[%s] Muting event notification for %s as ids aren\'t yet assigned!', accessory.displayName, change.characteristic.displayName) return } if (change.context != null && typeof change.context === 'object' && (change.context as CharacteristicOperationContext).omitEventUpdate) { debug('[%s] Omitting event updates for %s as specified in the context object!', accessory.displayName, change.characteristic.displayName) return } if (!(change.reason === ChangeReason.EVENT || change.oldValue !== change.newValue || change.characteristic.UUID === Characteristic.ProgrammableSwitchEvent.UUID // those specific checks are out of backwards compatibility || change.characteristic.UUID === Characteristic.ButtonEvent.UUID // new characteristics should use sendEventNotification call )) { // we only emit a change event if the reason was a call to sendEventNotification, if the value changed // as of a write request or a read request or if the change happened on dedicated event characteristics // otherwise we ignore this change event (with the return below) return } const uuid = change.characteristic.UUID const immediateDelivery = uuid === Characteristic.ButtonEvent.UUID || uuid === Characteristic.ProgrammableSwitchEvent.UUID || uuid === Characteristic.MotionDetected.UUID || uuid === Characteristic.ContactSensorState.UUID const value = formatOutgoingCharacteristicValue(change.newValue, change.characteristic.props) this._server.sendEventNotifications(accessory.aid, change.characteristic.iid, value, change.originator, immediateDelivery) } } private sendCharacteristicWarning(characteristic: Characteristic, type: CharacteristicWarningType, message: string): void { this.handleCharacteristicWarning({ characteristic, type, message, originatorChain: [characteristic.displayName], // we are missing the service displayName, but that's okay // eslint-disable-next-line unicorn/error-message -- only here to capture the stack stack: new Error().stack, }) } private handleCharacteristicWarning(warning: CharacteristicWarning): void { warning.originatorChain = [this.displayName, ...warning.originatorChain] const emitted = this.emit(AccessoryEventTypes.CHARACTERISTIC_WARNING, warning) if (!emitted) { const message = `[${warning.originatorChain.join('@')}] ${warning.message}` if (warning.type === CharacteristicWarningType.ERROR_MESSAGE || warning.type === CharacteristicWarningType.TIMEOUT_READ || warning.type === CharacteristicWarningType.TIMEOUT_WRITE) { console.error(message) } else { console.warn(message) } debug('[%s] Above characteristic warning was thrown at: %s', this.displayName, warning.stack ?? 'unknown') } } private setupServiceEventHandlers(service: Service): void { service.on(ServiceEventTypes.SERVICE_CONFIGURATION_CHANGE, this.handleServiceConfigurationChangeEvent.bind(this, service)) service.on(ServiceEventTypes.CHARACTERISTIC_CHANGE, this.handleCharacteristicChangeEvent.bind(this, this, service)) service.on(ServiceEventTypes.CHARACTERISTIC_WARNING, this.handleCharacteristicWarning.bind(this)) } private _sideloadServices(targetServices: Service[]): void { for (const service of targetServices) { this.setupServiceEventHandlers(service) } this.services = targetServices.slice() // the restored services carry their own primary flag; track it, so a later primary service replaces it for (const service of this.services) { if (!service.isPrimaryService) { continue } if (this.primaryService) { service.isPrimaryService = false // only one primary service, which a cache written before this fix may not hold to } else { this.primaryService = service } } } private static _generateSetupID(): string { const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ' const max = chars.length let setupID = '' for (let i = 0; i < 4; i++) { const index = Math.floor(Math.random() * max) setupID += chars.charAt(index) } return setupID } // serialization and deserialization functions, mainly designed for homebridge to create a json copy to store on disk public static serialize(accessory: Accessory): SerializedAccessory { const json: SerializedAccessory = { displayName: accessory.displayName, UUID: accessory.UUID, lastKnownUsername: accessory._accessoryInfo ? accessory._accessoryInfo.username : undefined, category: accessory.category, services: [], } const linkedServices: Record = {} let hasLinkedServices = false accessory.services.forEach((service) => { json.services.push(Service.serialize(service)) const linkedServicesPresentation: ServiceId[] = [] service.linkedServices.forEach((linkedService) => { linkedServicesPresentation.push(linkedService.getServiceId()) }) if (linkedServicesPresentation.length > 0) { linkedServices[service.getServiceId()] = linkedServicesPresentation hasLinkedServices = true } }) if (hasLinkedServices) { json.linkedServices = linkedServices } const controllers: SerializedControllerContext[] = [] // save controllers Object.values(accessory.controllers).forEach((context: ControllerContext) => { controllers.push({ type: context.controller.controllerId(), services: Accessory.serializeServiceMap(context.serviceMap), }) }) // also save controller which didn't get initialized (could lead to service duplication if we throw that data away) if (accessory.serializedControllers) { Object.entries(accessory.serializedControllers).forEach(([id, serviceMap]) => { controllers.push({ type: id, services: Accessory.serializeServiceMap(serviceMap), }) }) } if (controllers.length > 0) { json.controllers = controllers } return json } public static deserialize(json: SerializedAccessory): Accessory { const accessory = new Accessory(json.displayName, json.UUID) accessory.lastKnownUsername = json.lastKnownUsername accessory.category = json.category const services: Service[] = [] const servicesMap: Record = {} json.services.forEach((serialized) => { const service = Service.deserialize(serialized) services.push(service) servicesMap[service.getServiceId()] = service }) if (json.linkedServices) { for (const [serviceId, linkedServicesKeys] of Object.entries(json.linkedServices)) { const primaryService = servicesMap[serviceId] if (!primaryService) { continue } linkedServicesKeys.forEach((linkedServiceKey) => { const linkedService = servicesMap[linkedServiceKey] if (linkedService) { primaryService.addLinkedService(linkedService) } }) } } if (json.controllers) { // just save it for later if it exists {@see configureController} accessory.serializedControllers = {} json.controllers.forEach((serializedController) => { accessory.serializedControllers![serializedController.type] = Accessory.deserializeServiceMap(serializedController.services, servicesMap) }) } accessory._sideloadServices(services) return accessory } public static cleanupAccessoryData(username: MacAddress): void { IdentifierCache.remove(username) AccessoryInfo.remove(username) ControllerStorage.remove(username) } private static serializeServiceMap(serviceMap: ControllerServiceMap): SerializedServiceMap { const serialized: SerializedServiceMap = {} Object.entries(serviceMap).forEach(([name, service]) => { if (!service) { return } serialized[name] = service.getServiceId() }) return serialized } private static deserializeServiceMap(serializedServiceMap: SerializedServiceMap, servicesMap: Record): ControllerServiceMap { const controllerServiceMap: ControllerServiceMap = {} Object.entries(serializedServiceMap).forEach(([name, serviceId]) => { const service = servicesMap[serviceId] if (service) { controllerServiceMap[name] = service } }) return controllerServiceMap } private static parseBindOption(info: PublishInfo): { advertiserAddress?: string[] serviceRestrictedAddress?: string[] serviceDisableIpv6?: boolean serverAddress?: string } { let advertiserAddress: string[] | undefined let disableIpv6: boolean | undefined let serverAddress: string | undefined if (info.bind) { const entries: Set = new Set(Array.isArray(info.bind) ? info.bind : [info.bind]) if (entries.has('::')) { serverAddress = '::' entries.delete('::') if (entries.size) { advertiserAddress = Array.from(entries) } } else if (entries.has('0.0.0.0')) { disableIpv6 = true serverAddress = '0.0.0.0' entries.delete('0.0.0.0') if (entries.size) { advertiserAddress = Array.from(entries) } } else if (entries.size === 1) { advertiserAddress = Array.from(entries) const entry = entries.values().next().value // grab the first one const version = net.isIP(entry!) // check if ip address was specified or an interface name if (version) { serverAddress = version === 4 ? '0.0.0.0' : '::' // we currently bind to unspecified addresses so config-ui always has a connection via loopback } else { serverAddress = '::' // the interface could have both ipv4 and ipv6 addresses } } else if (entries.size > 1) { advertiserAddress = Array.from(entries) let bindUnspecifiedIpv6 = false // we bind on "::" if there are interface names, or we detect ipv6 addresses for (const entry of entries) { const version = net.isIP(entry) if (version === 0 || version === 6) { bindUnspecifiedIpv6 = true break } } if (bindUnspecifiedIpv6) { serverAddress = '::' } else { serverAddress = '0.0.0.0' } } } return { advertiserAddress, serviceRestrictedAddress: advertiserAddress, serviceDisableIpv6: disableIpv6, serverAddress, } } }