UNPKG

@homebridge/hap-client

Version:
559 lines 25.8 kB
import { createHash } from 'node:crypto'; import { EventEmitter } from 'node:events'; import axios from 'axios'; import Bonjour from 'bonjour-service'; import decamelize from 'decamelize'; import { titleize } from 'inflection'; import { Characteristics, Services } from './hap-types.js'; import { HapMonitor } from './monitor.js'; import { toLongFormUUID } from './uuid.js'; export * from './interfaces.js'; const IPV4_REGEX = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?!$)|$)){4}$/; export class HapClient extends EventEmitter { bonjour = new Bonjour(); browser; discoveryInProgress = false; defaultDiscoveryTimeout = 60000; defaultAutoStartDiscovery = true; logger; pin; debugEnabled = false; config; instances = []; hiddenServices = [Services.AccessoryInformation]; hiddenCharacteristics = [Characteristics.Name]; resetInstancePoolTimeout = undefined; startDiscoveryTimeout = undefined; hapMonitor; constructor(opts) { super(); this.pin = opts.pin; this.logger = opts.logger || console; this.debugEnabled = !!opts.config.debug; this.config = { ...opts.config, discoveryTimeout: opts.config.discoveryTimeout ?? this.defaultDiscoveryTimeout, autoStartDiscovery: opts.config.autoStartDiscovery ?? this.defaultAutoStartDiscovery, }; if (this.config.autoStartDiscovery) { this.startDiscovery(); } } logMessage(level, msg, includeStack = false) { if (!this.logger || typeof this.logger[level] !== 'function') { return; } const message = includeStack ? `${msg} @ ${new Error().stack?.split('\n')[3]?.trim()}` : msg; if (level === 'debug' && !this.debugEnabled) { return; } this.logger[level](message); } debug(msg) { this.logMessage('debug', msg, true); } info(msg) { this.logMessage('info', msg); } warn(msg) { this.logMessage('warn', msg); } error(msg) { this.logMessage('error', msg); } resetInstancePool() { if (this.discoveryInProgress) { this.browser?.stop(); this.browser?.removeAllListeners(); this.debug(`[HapClient] Discovery :: Terminated`); this.discoveryInProgress = false; this.emit('discovery-terminated'); } if (this.startDiscoveryTimeout) { clearTimeout(this.startDiscoveryTimeout); this.startDiscoveryTimeout = undefined; } if (this.resetInstancePoolTimeout) { clearTimeout(this.resetInstancePoolTimeout); } this.instances = []; this.resetInstancePoolTimeout = setTimeout(() => { this.refreshInstances(); }, 6000); } refreshInstances() { if (!this.discoveryInProgress) { this.startDiscovery(); } else { try { this.debug(`[HapClient] Discovery :: Re-broadcasting discovery query`); this.browser?.update(); } catch (e) { this.debug(`[HapClient] Discovery :: Failed to re-broadcast discovery query: ${e?.message ?? e}`); } } } startDiscovery(discoveryTimeout) { if (this.discoveryInProgress) { this.warn(`[HapClient] Discovery :: Already in progress`); return; } this.discoveryInProgress = true; const timeout = discoveryTimeout ?? this.config.discoveryTimeout; const browser = this.bonjour.find({ type: 'hap', }); this.browser = browser; browser.start(); this.debug(`[HapClient] Discovery :: Started`); this.startDiscoveryTimeout = setTimeout(() => { browser.stop(); this.debug(`[HapClient] Discovery :: Ended`); this.discoveryInProgress = false; this.emit('discovery-ended'); }, timeout); browser.on('up', async (device) => { if (!device || !device.txt) { this.debug(`[HapClient] Discovery :: Ignoring device that contains no txt records. ${JSON.stringify(device)}`); return; } if (typeof device.txt.id !== 'string' || !device.txt.id) { this.debug(`[HapClient] Discovery :: Ignoring device with missing or invalid id txt record. ${JSON.stringify(device)}`); return; } const instance = { name: device.txt.md, username: device.txt.id, ipAddress: null, port: device.port, services: [], connectionFailedCount: 0, configurationNumber: device.txt['c#'], }; this.debug(`[HapClient] Discovery :: Found HAP device with username ${instance.username}`); if (this.config.instanceBlacklist && this.config.instanceBlacklist.some(x => instance.username.toLowerCase() === x.toLowerCase())) { this.debug(`[HapClient] Discovery :: Instance with username ${instance.username} found in blacklist. Disregarding.`); return; } const existingInstanceIndex = this.instances.findIndex(x => x.username === instance.username); if (existingInstanceIndex > -1) { const configurationChanged = this.instances[existingInstanceIndex].configurationNumber !== instance.configurationNumber; if (this.instances[existingInstanceIndex].port !== instance.port || this.instances[existingInstanceIndex].name !== instance.name || configurationChanged) { this.instances[existingInstanceIndex].port = instance.port; this.instances[existingInstanceIndex].name = instance.name; this.instances[existingInstanceIndex].configurationNumber = instance.configurationNumber; this.debug(`[HapClient] Discovery :: [${this.instances[existingInstanceIndex].ipAddress}:${instance.port} ` + `(${instance.username})] Instance Updated`); this.emit('instance-discovered', this.instances[existingInstanceIndex]); if (configurationChanged) { this.emit('instance-configuration-changed', this.instances[existingInstanceIndex]); } this.hapMonitor?.refreshMonitorConnection(this.instances[existingInstanceIndex]); } else if (this.hapMonitor && this.hapMonitor.isInstanceMonitored(instance.username) && !this.hapMonitor.isInstanceConnected(instance.username)) { this.debug(`[HapClient] Discovery :: [${this.instances[existingInstanceIndex].ipAddress}:${instance.port} ` + `(${instance.username})] Instance re-announced with closed socket, reconnecting`); this.hapMonitor.refreshMonitorConnection(this.instances[existingInstanceIndex]); } return; } for (const ip of device.addresses) { if (IPV4_REGEX.test(ip)) { try { this.debug(`[HapClient] Discovery :: Testing ${instance.username} via http://${ip}:${device.port}/accessories`); const test = (await axios.get(`http://${ip}:${device.port}/accessories`, { timeout: 10000, })).data; if (test.accessories) { this.debug(`[HapClient] Discovery :: Success ${instance.username} via http://${ip}:${device.port}/accessories`); instance.ipAddress = ip; } break; } catch (e) { this.debug(`[HapClient] Discovery :: Failed ${instance.username} via http://${ip}:${device.port}/accessories`); this.debug(`[HapClient] Discovery :: Failed ${instance.username} with error: ${e.message}`); } } } if (instance.ipAddress && await this.checkInstanceConnection(instance)) { this.instances.push(instance); this.debug(`[HapClient] Discovery :: [${instance.ipAddress}:${instance.port} (${instance.username})] Instance Registered`); this.emit('instance-discovered', instance); this.hapMonitor?.refreshMonitorConnection(instance); } else { this.debug(`[HapClient] Discovery :: Could not register to device with username ${instance.username}`); } }); } stopDiscovery() { this.discoveryInProgress = false; this.browser?.stop(); this.browser?.removeAllListeners(); if (this.startDiscoveryTimeout) { clearTimeout(this.startDiscoveryTimeout); this.startDiscoveryTimeout = undefined; } this.debug(`[HapClient] Discovery :: Stopped`); this.emit('discovery-stopped'); } async checkInstanceConnection(instance) { try { await axios.put(`http://${instance.ipAddress}:${instance.port}/characteristics`, { characteristics: [{ aid: -1, iid: -1 }], }, { headers: { Authorization: this.pin, }, }); return true; } catch (e) { this.debug(`[HapClient] Discovery :: [${instance.ipAddress}:${instance.port} (${instance.username})] returned an error while attempting connection: ${e.message}`); return false; } } async getAccessories() { if (!this.instances.length) { this.debug('[HapClient] Cannot load accessories. No Homebridge instances have been discovered.'); } const accessories = []; for (const instance of this.instances) { try { const resp = (await axios.get(`http://${instance.ipAddress}:${instance.port}/accessories`)).data; instance.connectionFailedCount = 0; for (const accessory of resp.accessories) { accessory.instance = instance; accessories.push(accessory); } } catch { instance.connectionFailedCount++; this.debug(`[HapClient] [${instance.ipAddress}:${instance.port} (${instance.username})] Failed to connect`); if (instance.connectionFailedCount > 5) { const instanceIndex = this.instances.findIndex(x => x.username === instance.username && x.ipAddress === instance.ipAddress); if (instanceIndex > -1) { this.instances.splice(instanceIndex, 1); this.debug(`[HapClient] [${instance.ipAddress}:${instance.port} (${instance.username})] Removed From Instance Pool`); } } } } return accessories; } async monitorCharacteristics(services) { services = services ?? await this.getAllServices(); this.hapMonitor?.finish(); this.hapMonitor = new HapMonitor(this.logger, this.debug.bind(this), this.pin, services); return this.hapMonitor; } async getAllServices() { const accessories = await this.getAccessories(); const services = []; accessories.forEach((accessory) => { for (const service of accessory.services) { service.type = toLongFormUUID(service.type); for (const characteristic of service.characteristics) { characteristic.type = toLongFormUUID(characteristic.type); } } const accessoryInformationService = accessory.services.find(x => x.type === Services.AccessoryInformation); const accessoryInformation = {}; if (accessoryInformationService && accessoryInformationService.characteristics) { accessoryInformationService.characteristics.forEach((c) => { if (c.value) { accessoryInformation[c.description] = c.value; } }); } accessory.services .filter(s => !this.hiddenServices.includes(s.type) && Services[s.type]) .forEach((s) => { let serviceName = s.characteristics.find(x => x.type === Characteristics.Name); if (!serviceName || serviceName.value === null || serviceName.value === undefined) { serviceName = { iid: 0, type: Characteristics.Name, description: 'Name', format: 'string', value: accessoryInformation.Name || this.humanizeString(Services[s.type]), perms: ['pr'], }; } const serviceCharacteristics = s.characteristics .filter(c => !this.hiddenCharacteristics.includes(c.type) && Characteristics[c.type]) .map((c) => { return { aid: accessory.aid, iid: c.iid, uuid: c.type, type: Characteristics[c.type], serviceType: Services[s.type], serviceName: serviceName.value.toString(), description: c.description, value: c.value, format: c.format, perms: c.perms, unit: c.unit, maxValue: c.maxValue, minValue: c.minValue, minStep: c.minStep, validValues: c['valid-values'], canRead: c.perms.includes('pr'), canWrite: c.perms.includes('pw'), ev: c.perms.includes('ev'), }; }); const service = { aid: accessory.aid, iid: s.iid, uuid: s.type, type: Services[s.type], humanType: this.humanizeString(Services[s.type]), serviceName: (serviceName.value.toString().length ? serviceName.value.toString() : accessoryInformation.Name), serviceCharacteristics, accessoryInformation, values: {}, linked: s.linked, instance: accessory.instance, }; service.uniqueId = createHash('sha256') .update(`${service.instance.username}${service.aid}${service.iid}${service.type}`) .digest('hex'); service.nameBasedUniqueId = createHash('sha256') .update([service.instance.name, service.instance.username, service.accessoryInformation.Manufacturer, service.serviceName, service.uuid.slice(0, 8)].join('|')) .digest('hex'); service.refreshCharacteristics = () => { return this.refreshServiceCharacteristics.bind(this)(service); }; service.setCharacteristic = (iid, value) => { return this.setCharacteristic.bind(this)(service, iid, value); }; service.setCharacteristicByType = (type, value) => { return this.setCharacteristicByType.bind(this)(service, type, value); }; service.setCharacteristicsByTypes = (payload) => { return this.setCharacteristicsByTypes.bind(this)(service, payload); }; service.getCharacteristic = (type) => { return service.serviceCharacteristics.find(c => c.type === type); }; if (service.type === 'CameraRTPStreamManagement') { service.getResource = (body) => { return this.getResource.bind(this)(service, body); }; } service.serviceCharacteristics.forEach((c) => { c.setValue = async (value) => { return await this.setCharacteristic.bind(this)(service, c.iid, value); }; c.getValue = async () => { return await this.getCharacteristic.bind(this)(service, c.iid); }; service.values[c.type] = c.value; }); services.push(service); }); }); return services; } async getService(iid) { const services = await this.getAllServices(); return services.find(x => x.iid === iid); } async getServiceByName(serviceName) { const services = await this.getAllServices(); return services.find(x => x.serviceName === serviceName); } async refreshServiceCharacteristics(service) { try { const iids = service.serviceCharacteristics.map(c => c.iid); const resp = (await axios.get(`http://${service.instance.ipAddress}:${service.instance.port}/characteristics`, { params: { id: iids.map(iid => `${service.aid}.${iid}`).join(','), }, })).data; resp.characteristics.forEach((c) => { const characteristic = service.serviceCharacteristics.find(x => x.iid === c.iid && x.aid === service.aid); if (!characteristic || c.value === undefined) { return; } characteristic.value = c.value; service.values[characteristic.type] = c.value; }); return service; } catch (e) { this.debug(`[HapClient] +${e}`); this.error(`[HapClient] Failed to refresh characteristics for ${service.serviceName}: ${e.message}`); } } async getCharacteristic(service, iid) { try { const resp = (await axios.get(`http://${service.instance.ipAddress}:${service.instance.port}/characteristics`, { params: { id: `${service.aid}.${iid}`, }, })).data; const respCharacteristic = resp.characteristics?.[0]; if (!respCharacteristic) { return undefined; } const characteristic = service.serviceCharacteristics.find(x => x.iid === respCharacteristic.iid && x.aid === service.aid); if (!characteristic) { return undefined; } if (respCharacteristic.value !== undefined) { characteristic.value = respCharacteristic.value; service.values[characteristic.type] = respCharacteristic.value; } return characteristic; } catch (e) { this.debug(`[HapClient] +${e}`); this.error(`[HapClient] Failed to get characteristic for ${service.serviceName} with iid ${iid}: ${e.message}`); } } async setCharacteristicByType(service, type, value) { const characteristic = service.serviceCharacteristics.find(x => x.type === type); if (!characteristic) { throw new Error(`Characteristic ${type} not found in service ${service.serviceName}`); } return this.setCharacteristic(service, characteristic.iid, value); } async setCharacteristic(service, iid, value) { try { await axios.put(`http://${service.instance.ipAddress}:${service.instance.port}/characteristics`, { characteristics: [ { aid: service.aid, iid, value, }, ], }, { headers: { Authorization: this.pin, }, }); return this.getCharacteristic(service, iid); } catch (e) { this.error(`[HapClient] [${service.instance.ipAddress}:${service.instance.port} (${service.instance.username})] ` + `Failed to set value for ${service.serviceName}.`); if (e.response && (e.response?.status === 470 || e.response?.status === 401)) { this.warn(`[HapClient] [${service.instance.ipAddress}:${service.instance.port} (${service.instance.username})] ` + `Make sure Homebridge pin for this instance is set to ${this.pin}.`); throw new Error(`Failed to control accessory. Make sure the Homebridge pin for ${service.instance.ipAddress}:${service.instance.port} ` + `is set to ${this.pin}.`); } else { this.error(e.message); throw new Error(`Failed to control accessory: ${e.message}`); } } } async setCharacteristicsByTypes(service, payload) { const characteristics = Object.entries(payload).map(([type, value]) => { const characteristic = service.serviceCharacteristics.find(x => x.type === type); if (!characteristic) { throw new Error(`Characteristic ${type} not found in service ${service.serviceName}`); } if (type === 'Configured Name') { return null; } return { aid: service.aid, iid: characteristic.iid, value, }; }).filter(item => item !== null); if (!characteristics.length) { return service; } return this.setCharacteristics(service, characteristics); } async setCharacteristics(service, characteristics) { try { await axios.put(`http://${service.instance.ipAddress}:${service.instance.port}/characteristics`, { characteristics, }, { headers: { Authorization: this.pin, }, }); return this.refreshServiceCharacteristics(service); } catch (e) { this.error(`[HapClient] [${service.instance.ipAddress}:${service.instance.port} (${service.instance.username})] ` + `Failed to set value for ${service.serviceName}.`); if (e.response && (e.response?.status === 470 || e.response?.status === 401)) { this.warn(`[HapClient] [${service.instance.ipAddress}:${service.instance.port} (${service.instance.username})] ` + `Make sure Homebridge pin for this instance is set to ${this.pin}.`); throw new Error(`Failed to control accessory. Make sure the Homebridge pin for ${service.instance.ipAddress}:${service.instance.port} ` + `is set to ${this.pin}.`); } else { this.error(e.message); throw new Error(`Failed to control accessory: ${e.message}`); } } } async getResource(service, body) { try { const resp = await axios.post(`http://${service.instance.ipAddress}:${service.instance.port}/resource`, { ...body, aid: service.aid, }, { responseType: 'arraybuffer', headers: { Authorization: this.pin, }, }); if (resp.status === 200) { return resp.data; } else { this.warn(`[HapClient] getResource [${service.instance.ipAddress}:${service.instance.port} (${service.instance.username})] ` + `Failed to request resource from accessory ${service.serviceName}. Response status Code ${resp.status}`); } } catch (e) { this.error(`[HapClient] [${service.instance.ipAddress}:${service.instance.port} (${service.instance.username})] ` + `Failed to request resource from accessory ${service.serviceName}.`); if (e.response && (e.response?.status === 470 || e.response?.status === 401)) { this.warn(`[HapClient] [${service.instance.ipAddress}:${service.instance.port} (${service.instance.username})] ` + `Make sure Homebridge pin for this instance is set to ${this.pin}.`); throw new Error(`Failed to request resource from accessory. Make sure the Homebridge pin for ${service.instance.ipAddress}:${service.instance.port} ` + `is set to ${this.pin}.`); } else { this.error(e.message); throw new Error(`Failed to request resource: ${e.message}`); } } } humanizeString(string) { return titleize(decamelize(string)); } destroy() { this.browser?.stop(); this.hapMonitor?.finish(); this.discoveryInProgress = false; if (this.resetInstancePoolTimeout) { clearTimeout(this.resetInstancePoolTimeout); } if (this.startDiscoveryTimeout) { clearTimeout(this.startDiscoveryTimeout); } this.bonjour.destroy(); } } //# sourceMappingURL=index.js.map