UNPKG

hap-controller

Version:

Library to implement a HAP (HomeKit) controller

661 lines 28 kB
"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