hap-controller
Version:
Library to implement a HAP (HomeKit) controller
661 lines • 28 kB
JavaScript
"use strict";
/**
* Controller class for interacting with a HAP device over HTTP.
*/
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 __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const events_1 = require("events");
const http_connection_1 = __importDefault(require("./http-connection"));
const pairing_protocol_1 = __importStar(require("../../protocol/pairing-protocol"));
const debug_1 = __importDefault(require("debug"));
const Characteristic = __importStar(require("../../model/characteristic"));
const Service = __importStar(require("../../model/service"));
const error_1 = __importDefault(require("../../model/error"));
const queue_1 = require("../../utils/queue");
const json_bigint_1 = __importDefault(require("json-bigint"));
const bignumber_js_1 = __importDefault(require("bignumber.js"));
const debug = (0, debug_1.default)('hap-controller:http-client');
class HttpClient extends events_1.EventEmitter {
/**
* Initialize the HttpClient object.
*
* @param {string} deviceId - ID of the device
* @param {string} address - IP address of the device
* @param {number} port - HTTP port
* @param {PairingData?} pairingData - existing pairing data
* @param {HttpClientOptions} options - additional options
*/
constructor(deviceId, address, port, pairingData, options) {
super();
this.usePersistentConnections = true;
this.subscriptionsUseSameConnection = false;
this.subscribedCharacteristics = [];
this.deviceId = deviceId;
this.address = address;
this.port = port;
this.pairingProtocol = new pairing_protocol_1.default(pairingData);
this.pairingQueue = new queue_1.OpQueue();
this.usePersistentConnections = (options === null || options === void 0 ? void 0 : options.usePersistentConnections) || false;
this.subscriptionsUseSameConnection = (options === null || options === void 0 ? void 0 : options.subscriptionsUseSameConnection) || false;
}
/**
* Initialize or return an existing connection
*
* @private
* @returns {Promise<HttpConnection>} The connection to use
*/
async getDefaultVerifiedConnection() {
if (this._defaultConnection) {
debug(`${this.address}:${this.port} Reuse persistent connection client`);
return this._defaultConnection;
}
const connection = new http_connection_1.default(this.address, this.port);
const keys = await this._pairVerify(connection);
connection.setSessionKeys(keys);
if (this.usePersistentConnections) {
this._defaultConnection = connection;
this._defaultConnection.on('disconnect', () => {
debug(`${this.address}:${this.port} Persistent connection client got disconnected`);
});
debug(`${this.address}:${this.port} New persistent connection client initialized`);
}
else {
debug(`${this.address}:${this.port} New new connection client initialized`);
}
return connection;
}
/**
* Checks if a maybe persistent connection should be closed
*
* @param {HttpConnection} connection Connection which was returned by getDefaultVerifiedConnection()
* @param {boolean} forceClose - Force close the connection
* @private
*/
closeMaybePersistentConnection(connection, forceClose = false) {
if (!this.usePersistentConnections || this._defaultConnection !== connection || forceClose) {
connection.close();
debug(`${this.address}:${this.port} Close client connection`);
}
}
/**
* Queue an operation for the pairing.
*
* @param {function} op - Function to add to the queue
* @returns {Promise} Promise which resolves when the function is called.
*/
_queuePairingOperation(op) {
return this.pairingQueue.queue(op);
}
/**
* Get the data (keys) that needs to be stored long-term.
*
* @returns {PairingData} Object containing the keys that should be stored.
*/
getLongTermData() {
return this.pairingProtocol.getLongTermData();
}
/**
* Run the identify routine on a device.
*
* This can only be done before pairing.
* If the device is already paired the method returns an error (Identify failed with status 400)
*
* @returns {Promise} Promise which resolves if identify succeeded.
*/
async identify() {
const connection = new http_connection_1.default(this.address, this.port);
const response = await connection.post('/identify', Buffer.alloc(0));
connection.close();
if (response.statusCode !== 204) {
throw new error_1.default(`Identify failed with status ${response.statusCode}`, response.statusCode, response.body);
}
}
/**
* Verify the provided PIN
*
* @param pin {string} PIN
*/
verifyPin(pin) {
this.pairingProtocol.verifyPin(pin);
}
/**
* Begins the pairing process. For devices with random pins, this
* will cause it to show the pin on the screen.
*
* @param {PairMethods} [pairMethod] - Method to use for pairing, default is PairSetupWithAuth
* @param {PairingTypeFlags} [pairFlags] - Flags to use for Pairing for PairSetup
* * No provided flags is equivalent to providing
* kPairingFlag_Transient and kPairingFlag_Split and in this case a new
* code is generated randomly by the device (if supported) or the
* pre-defined code is used
* * If only the flag kPairingFlag_Split is provided the code which
* was created on the device from last transient+split call is reused
* and needs to be provided in finishPairing by user of this library
* * If only the flag kPairingFlag_Transient is provided the session
* security is enabled but no final pairing is done
* @returns {Promise} Promise which resolves to opaque pairing data when complete.
*/
async startPairing(pairMethod = pairing_protocol_1.PairMethods.PairSetupWithAuth, pairFlags = 0) {
const connection = (this._pairingConnection = new http_connection_1.default(this.address, this.port));
// M1
const m1 = await this.pairingProtocol.buildPairSetupM1(pairMethod, pairFlags);
const m2 = await connection.post('/pair-setup', m1, 'application/pairing+tlv8');
// M2
try {
return this.pairingProtocol.parsePairSetupM2(m2.body);
}
catch (e) {
// Close connection if we have an error
connection.close();
delete this._pairingConnection;
throw e;
}
}
/**
* Finishes a pairing process that began with startPairing()
*
* @param {TLV} pairingData - The pairing data returned from startPairing()
* @param {string} pin - The pairing PIN, needs to be formatted as XXX-XX-XXX
* @returns {Promise} Promise which resolve when pairing is complete.
*/
async finishPairing(pairingData, pin) {
if (!pairingData || !this._pairingConnection) {
throw new Error('Must call startPairing() first');
}
this.verifyPin(pin);
const connection = this._pairingConnection;
delete this._pairingConnection;
try {
// M3
const m3 = await this.pairingProtocol.buildPairSetupM3(pairingData, pin);
const m4 = await connection.post('/pair-setup', m3, 'application/pairing+tlv8');
// M4
await this.pairingProtocol.parsePairSetupM4(m4.body);
if (!this.pairingProtocol.isTransientOnlyPairSetup()) {
// According to specs for a transient pairSetup process no M5/6 is done, which should end in a
// "non pairing" result, and we miss AccessoryId and AccessoryLTPK, but the current session is
// authenticated
// M5
const m5 = await this.pairingProtocol.buildPairSetupM5();
const m6 = await connection.post('/pair-setup', m5, 'application/pairing+tlv8');
// M6
await this.pairingProtocol.parsePairSetupM6(m6.body);
}
}
finally {
connection.close();
}
}
/**
* Attempt to pair with a device.
*
* @param {string} pin - The pairing PIN, needs to be formatted as XXX-XX-XXX
* @param {PairMethods} [pairMethod] - Method to use for pairing, default is PairSetupWithAuth
* @param {PairingTypeFlags} [pairFlags] - Flags to use for Pairing for PairSetup
* @returns {Promise} Promise which resolves when pairing is complete.
*/
async pairSetup(pin, pairMethod = pairing_protocol_1.PairMethods.PairSetupWithAuth, pairFlags = 0) {
await this.finishPairing(await this.startPairing(pairMethod, pairFlags), pin);
}
/**
* Method used internally to generate session keys for a connection.
*
* @private
* @param {Object} connection - Existing HttpConnection object
* @returns {Promise} Promise which resolves to the generated session keys.
*/
async _pairVerify(connection) {
return this._queuePairingOperation(async () => {
debug(`${this.address}:${this.port} Start Pair-Verify process ...`);
// M1
const m1 = await this.pairingProtocol.buildPairVerifyM1();
const m2 = await connection.post('/pair-verify', m1, 'application/pairing+tlv8');
// M2
await this.pairingProtocol.parsePairVerifyM2(m2.body);
// M3
const m3 = await this.pairingProtocol.buildPairVerifyM3();
const m4 = await connection.post('/pair-verify', m3, 'application/pairing+tlv8');
// M4
await this.pairingProtocol.parsePairVerifyM4(m4.body);
debug(`${this.address}:${this.port} Finished Pair-Verify process ...`);
return this.pairingProtocol.getSessionKeys();
});
}
/**
* Unpair the controller from a device.
*
* @param {string | Buffer} identifier - Identifier of the controller to remove
* @returns {Promise} Promise which resolves when the process completes.
*/
async removePairing(identifier) {
const connection = await this.getDefaultVerifiedConnection();
if (typeof identifier === 'string') {
identifier = pairing_protocol_1.default.bufferFromHex(identifier);
}
try {
// M1
const m1 = await this.pairingProtocol.buildRemovePairingM1(identifier);
const m2 = await connection.post('/pairings', m1, 'application/pairing+tlv8');
// M2
await this.pairingProtocol.parseRemovePairingM2(m2.body);
}
finally {
this.closeMaybePersistentConnection(connection, true);
}
}
/**
* Add a pairing to a device.
*
* @param {string} identifier - Identifier of new controller
* @param {Buffer} ltpk - Long-term public key of the new controller
* @param {boolean} isAdmin - Whether or not the new controller is an admin
* @returns {Promise} Promise which resolves when the process is complete.
*/
async addPairing(identifier, ltpk, isAdmin) {
const connection = await this.getDefaultVerifiedConnection();
try {
// M1
const m1 = await this.pairingProtocol.buildAddPairingM1(identifier, ltpk, isAdmin);
const m2 = await connection.post('/pairings', m1, 'application/pairing+tlv8');
// M2
await this.pairingProtocol.parseAddPairingM2(m2.body);
}
finally {
this.closeMaybePersistentConnection(connection);
}
}
/**
* List the pairings on a device.
*
* @returns {Promise} Promise which resolves to the final TLV when the process
* is complete.
*/
async listPairings() {
const connection = await this.getDefaultVerifiedConnection();
try {
// M1
const m1 = await this.pairingProtocol.buildListPairingsM1();
const m2 = await connection.post('/pairings', m1, 'application/pairing+tlv8');
// M2
return this.pairingProtocol.parseListPairingsM2(m2.body);
}
finally {
this.closeMaybePersistentConnection(connection);
}
}
/**
* Get the accessory attribute database from a device.
*
* @returns {Promise} Promise which resolves to the JSON document.
*/
async getAccessories() {
const connection = await this.getDefaultVerifiedConnection();
try {
const response = await connection.get('/accessories');
if (response.statusCode !== 200) {
throw new error_1.default(`Get failed with status ${response.statusCode}`, response.statusCode, response.body);
}
const res = json_bigint_1.default.parse(response.body.toString());
res.accessories.forEach((accessory) => {
accessory.services.forEach((service) => {
service.type = Service.ensureServiceUuid(service.type);
service.characteristics.forEach((characteristic) => {
characteristic.type = Characteristic.ensureCharacteristicUuid(characteristic.type);
});
});
});
return res;
}
finally {
this.closeMaybePersistentConnection(connection);
}
}
/**
* Read a set of characteristics.
*
* @param {string[]} characteristics - List of characteristics ID to get in form ["aid.iid", ...]
* @param {GetCharacteristicsOptions?} options - Options dictating what metadata to fetch
* @returns {Promise} Promise which resolves to the JSON document.
*/
async getCharacteristics(characteristics, options = {}) {
options = Object.assign({
meta: false,
perms: false,
type: false,
ev: false,
}, options);
const connection = await this.getDefaultVerifiedConnection();
let path = `/characteristics?id=${characteristics.join(',')}`;
if (options.meta) {
path += '&meta=1';
}
if (options.perms) {
path += '&perms=1';
}
if (options.type) {
path += '&type=1';
}
if (options.ev) {
path += '&ev=1';
}
try {
const response = await connection.get(path);
if (response.statusCode !== 200 && response.statusCode !== 207) {
throw new error_1.default(`Get failed with status ${response.statusCode}`, response.statusCode, response.body);
}
return json_bigint_1.default.parse(response.body.toString());
}
finally {
this.closeMaybePersistentConnection(connection);
}
}
/**
* Modify a set of characteristics.
*
* @param {Object} characteristics - Characteristic IDs to set in form
* * id -> val or
* * id -> SetCharacteristicsObject
* @returns {Promise} Promise which resolves to the JSON document.
*/
async setCharacteristics(characteristics) {
const connection = await this.getDefaultVerifiedConnection();
const data = {
characteristics: [],
};
for (const cid in characteristics) {
const parts = cid.split('.');
let dataObject = {
aid: (0, bignumber_js_1.default)(parts[0].trim()),
iid: (0, bignumber_js_1.default)(parts[1].trim()),
value: null,
};
if (typeof characteristics[cid] === 'object' &&
characteristics[cid] !== null &&
// eslint-disable-next-line no-undefined
characteristics[cid].value !== undefined) {
dataObject = Object.assign(dataObject, characteristics[cid]);
}
else {
dataObject.value = characteristics[cid];
}
data.characteristics.push(dataObject);
}
try {
const response = await connection.put('/characteristics', Buffer.from(json_bigint_1.default.stringify(data)));
if (response.statusCode === 204) {
return data;
}
else if (response.statusCode === 207) {
return json_bigint_1.default.parse(response.body.toString());
}
else {
throw new error_1.default(`Set failed with status ${response.statusCode}`, response.statusCode, response.body);
}
}
finally {
this.closeMaybePersistentConnection(connection);
}
}
/**
* Subscribe to events for a set of characteristics.
*
* @fires HttpClient#event
* @fires HttpClient#event-disconnect
* @param {String[]} characteristics - List of characteristic IDs to subscribe to,
* in form ["aid.iid", ...]
* @returns {Promise} Promise
*/
async subscribeCharacteristics(characteristics) {
let connection;
if (this.subscriptionsUseSameConnection) {
connection = await this.getDefaultVerifiedConnection();
}
else {
connection = this.subscriptionConnection || new http_connection_1.default(this.address, this.port);
}
const data = {
characteristics: [],
};
if (!this.subscriptionConnection && !this.subscriptionsUseSameConnection) {
const keys = await this._pairVerify(connection);
connection.setSessionKeys(keys);
}
const newSubscriptions = [];
for (const cid of characteristics) {
if (this.subscribedCharacteristics.includes(cid)) {
// cid already subscribed, so we do not need to subscribe again
continue;
}
newSubscriptions.push(cid);
const parts = cid.split('.');
data.characteristics.push({
aid: (0, bignumber_js_1.default)(parts[0].trim()),
iid: (0, bignumber_js_1.default)(parts[1].trim()),
ev: true,
});
}
if (data.characteristics.length) {
if (!this.subscriptionConnection) {
connection.on('event', (ev) => {
/**
* Event emitted with characteristic value changes
*
* @event HttpClient#event
* @type {Object} Event TODO
*/
this.emit('event', json_bigint_1.default.parse(ev));
});
connection.once('disconnect', () => {
connection.removeAllListeners('event');
delete this.subscriptionConnection;
if (this.subscribedCharacteristics.length) {
/**
* Event emitted when subscription connection got disconnected, but
* still some characteristics are subscribed.
* You need to manually resubscribe!
*
* @event HttpClient#event-disconnect
* @type {string[]} List of the subscribed characteristics for resubscribe handling
*/
this.emit('event-disconnect', this.subscribedCharacteristics);
this.subscribedCharacteristics = [];
}
});
this.subscriptionConnection = connection;
}
const response = await connection.put('/characteristics', Buffer.from(json_bigint_1.default.stringify(data)), 'application/hap+json', true);
if (response.statusCode !== 204 && response.statusCode !== 207) {
if (!this.subscribedCharacteristics.length) {
if (!this.subscriptionsUseSameConnection) {
connection.close();
}
connection.removeAllListeners('event');
delete this.subscriptionConnection;
}
throw new error_1.default(`Subscribe failed with status ${response.statusCode}`, response.statusCode, response.body);
}
this.subscribedCharacteristics = this.subscribedCharacteristics.concat(newSubscriptions);
let body = {};
try {
if (response.body) {
body = json_bigint_1.default.parse(response.body.toString());
}
}
catch (err) {
// ignore
}
return body;
}
return null;
}
/**
* Unsubscribe from events for a set of characteristics.
*
* @param {String[]} characteristics - List of characteristic IDs to
* unsubscribe from in form ["aid.iid", ...],
* if ommited all currently subscribed characteristics will be unsubscribed
* @returns {Promise} Promise which resolves when the procedure is done.
*/
async unsubscribeCharacteristics(characteristics) {
var _a;
if (!this.subscriptionConnection || !this.subscribedCharacteristics.length) {
return null;
}
if (!characteristics) {
characteristics = this.subscribedCharacteristics;
}
const data = {
characteristics: [],
};
const unsubscribedCharacteristics = [];
for (const cid of characteristics) {
if (this.subscribedCharacteristics.includes(cid)) {
continue;
}
unsubscribedCharacteristics.push(cid);
const parts = cid.split('.');
data.characteristics.push({
aid: (0, bignumber_js_1.default)(parts[0].trim()),
iid: (0, bignumber_js_1.default)(parts[1].trim()),
ev: false,
});
}
if (data.characteristics.length) {
const response = await this.subscriptionConnection.put('/characteristics', Buffer.from(json_bigint_1.default.stringify(data)));
if (response.statusCode !== 204 && response.statusCode !== 207) {
throw new error_1.default(`Unsubscribe failed with status ${response.statusCode}`, response.statusCode, response.body);
}
unsubscribedCharacteristics.forEach((characteristic) => {
const index = this.subscribedCharacteristics.indexOf(characteristic);
if (index > -1) {
this.subscribedCharacteristics.splice(index, 1);
}
});
if (!this.subscribedCharacteristics.length) {
if (!this.subscriptionsUseSameConnection) {
this.subscriptionConnection.close();
}
(_a = this.subscriptionConnection) === null || _a === void 0 ? void 0 : _a.removeAllListeners('event');
delete this.subscriptionConnection;
}
let body = {};
try {
if (response.body) {
body = json_bigint_1.default.parse(response.body.toString());
}
}
catch (err) {
// ignore
}
return body;
}
return null;
}
/**
* Get the list of subscribed characteristics
*
* @returns {string[]} Array with subscribed entries in form ["aid.iid", ...]
*/
getSubscribedCharacteristics() {
return this.subscribedCharacteristics;
}
/**
* Get an JPEG image with a snapshot from the devices camera
*
* @param {number} width width of the returned image
* @param {number} height height of the returned image
* @param {number | BigNumber} [aid] accessory ID (optional)
*
* @returns {Promise<Buffer>} Promise which resolves to a Buffer with the JPEG image content
*/
async getImage(width, height, aid) {
const connection = await this.getDefaultVerifiedConnection();
const data = {
aid,
'resource-type': 'image',
'image-width': width,
'image-height': height,
};
try {
const response = await connection.post('/resource', Buffer.from(json_bigint_1.default.stringify(data)));
if (response.statusCode !== 200) {
throw new error_1.default(`Image request errored with status ${response.statusCode}`, response.statusCode, response.body);
}
return response.body;
}
finally {
this.closeMaybePersistentConnection(connection);
}
}
/**
* Closes the current persistent connection, if connected
*/
closePersistentConnection() {
var _a;
try {
(_a = this._defaultConnection) === null || _a === void 0 ? void 0 : _a.close();
}
catch (_b) {
// ignore
}
}
/**
* Close all potential open connections to the device
*
* @returns {Promise<void>} Promise when done
*/
async close() {
var _a, _b, _c;
try {
(_a = this._defaultConnection) === null || _a === void 0 ? void 0 : _a.close();
}
catch (_d) {
// ignore
}
delete this._defaultConnection;
try {
(_b = this._pairingConnection) === null || _b === void 0 ? void 0 : _b.close();
}
catch (_e) {
// ignore
}
delete this._pairingConnection;
if (!this.subscriptionsUseSameConnection) {
try {
(_c = this.subscriptionConnection) === null || _c === void 0 ? void 0 : _c.close();
}
catch (_f) {
// ignore
}
}
delete this.subscriptionConnection;
this.subscribedCharacteristics = [];
}
}
exports.default = HttpClient;
//# sourceMappingURL=http-client.js.map