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