UNPKG

@homebridge/hap-client

Version:
512 lines 24 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __exportStar = (this && this.__exportStar) || function(m, exports) { for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.HapClient = void 0; const node_crypto_1 = require("node:crypto"); const node_events_1 = require("node:events"); const axios_1 = __importDefault(require("axios")); const bonjour_service_1 = __importDefault(require("bonjour-service")); const decamelize_1 = __importDefault(require("decamelize")); const inflection_1 = require("inflection"); require("source-map-support/register"); const hap_types_1 = require("./hap-types"); const monitor_1 = require("./monitor"); const uuid_1 = require("./uuid"); __exportStar(require("./interfaces"), exports); class HapClient extends node_events_1.EventEmitter { bonjour = new bonjour_service_1.default(); browser; discoveryInProgress = false; logger; pin; debugEnabled = false; config; instances = []; hiddenServices = [hap_types_1.Services.AccessoryInformation]; hiddenCharacteristics = [hap_types_1.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; 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.debug(`[HapClient] Discovery :: Terminated`); this.discoveryInProgress = false; this.emit('discovery-terminated'); } 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 { } } } async startDiscovery() { this.discoveryInProgress = true; this.browser = this.bonjour.find({ type: 'hap', }); this.browser.start(); this.debug(`[HapClient] Discovery :: Started`); this.startDiscoveryTimeout = setTimeout(() => { this.browser.stop(); this.debug(`[HapClient] Discovery :: Ended`); this.discoveryInProgress = false; this.emit('discovery-ended'); }, 60000); this.browser.on('up', async (device) => { if (!device || !device.txt) { this.debug(`[HapClient] Discovery :: Ignoring device that contains no txt records. ${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}`); 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]); } return; } if (this.config.instanceBlacklist && this.config.instanceBlacklist.find(x => instance.username.toLowerCase() === x.toLowerCase())) { this.debug(`[HapClient] Discovery :: Instance with username ${instance.username} found in blacklist. Disregarding.`); return; } for (const ip of device.addresses) { if (ip.match(/^(?:(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])(\.(?!$)|$)){4}$/)) { try { this.debug(`[HapClient] Discovery :: Testing ${instance.username} via http://${ip}:${device.port}/accessories`); const test = (await axios_1.default.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}`); } }); } async checkInstanceConnection(instance) { try { await axios_1.default.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_1.default.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); 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 = new monitor_1.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 = (0, uuid_1.toLongFormUUID)(service.type); for (const characteristic of service.characteristics) { characteristic.type = (0, uuid_1.toLongFormUUID)(characteristic.type); } } const accessoryInformationService = accessory.services.find(x => x.type === hap_types_1.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.indexOf(s.type) < 0 && hap_types_1.Services[s.type]) .map((s) => { let serviceName = s.characteristics.find(x => x.type === hap_types_1.Characteristics.Name); serviceName = serviceName ? serviceName : { iid: 0, type: hap_types_1.Characteristics.Name, description: 'Name', format: 'string', value: accessoryInformation.Name || this.humanizeString(hap_types_1.Services[s.type]), perms: ['pr'], }; const serviceCharacteristics = s.characteristics .filter((c) => this.hiddenCharacteristics.indexOf(c.type) < 0 && hap_types_1.Characteristics[c.type]) .map((c) => { return { aid: accessory.aid, iid: c.iid, uuid: c.type, type: hap_types_1.Characteristics[c.type], serviceType: hap_types_1.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: hap_types_1.Services[s.type], humanType: this.humanizeString(hap_types_1.Services[s.type]), serviceName: (serviceName.value.toString().length ? serviceName.value.toString() : accessoryInformation.Name), serviceCharacteristics, accessoryInformation, values: {}, linked: s.linked, instance: accessory.instance, }; service.uniqueId = (0, node_crypto_1.createHash)('sha256') .update(`${service.instance.username}${service.aid}${service.iid}${service.type}`) .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_1.default.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); 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_1.default.get(`http://${service.instance.ipAddress}:${service.instance.port}/characteristics`, { params: { id: `${service.aid}.${iid}`, }, })).data; const characteristic = service.serviceCharacteristics.find(x => x.iid === resp.characteristics[0].iid && x.aid === service.aid); characteristic.value = resp.characteristics[0].value; service.values[characteristic.type] = resp.characteristics[0].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_1.default.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); return this.setCharacteristics(service, characteristics); } async setCharacteristics(service, characteristics) { try { await axios_1.default.put(`http://${service.instance.ipAddress}:${service.instance.port}/characteristics`, { 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_1.default.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}`); } return; } 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 (0, inflection_1.titleize)((0, decamelize_1.default)(string)); } async 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(); } } exports.HapClient = HapClient; //# sourceMappingURL=index.js.map