@homebridge/hap-client
Version:
A client for HAP-NodeJS.
559 lines • 25.8 kB
JavaScript
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