UNPKG

homebridge-jci-hitachi-platform

Version:

Homebridge platform plugin providing HomeKit support for Jci Hitachi air conditioners.

387 lines 18.1 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const axios_1 = __importDefault(require("axios")); const aws_iot_device_sdk_v2_1 = require("aws-iot-device-sdk-v2"); const events_1 = require("events"); const util_utf8_browser_1 = require("@aws-sdk/util-utf8-browser"); const jci_hitachi_constants_1 = require("./jci-hitachi-constants"); const jci_hitachi_connections_1 = require("./jci-hitachi-connections"); // How long Login() waits for the MQTT connection to be established before // giving up and letting the platform's backoff retry with fresh credentials. const MQTT_CONNECT_TIMEOUT = 30 * 1000; // Upper bound for MQTT teardown steps (unsubscribe/stop) during Logout(). const MQTT_TEARDOWN_TIMEOUT = 5 * 1000; function withTimeout(promise, ms, label) { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms} ms`)), ms); promise.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); }); }); } function generateRandomHex(length) { const characters = 'abcdef0123456789'; let result = ''; for (let i = 0; i < length; i++) { const randomIndex = Math.floor(Math.random() * characters.length); result += characters[randomIndex]; } return result; } class JciHitachiAWSAPI { constructor(email, password, log) { this.task_id = 0; this.is_host = false; this.last_received_time = 0; this.isConnected = false; this.isLoginFailed = false; this.isLoggingIn = false; this.email = email; this.password = password; this.log = log; } deconstructor() { this.Logout(); } setCallback(callback) { this.callback = callback; } async Login() { var _a; // Many callers (the platform retry timer, every accessory's refresh interval and // the MQTT stale-connection check) can ask to log in at the same time. Coalesce // them into a single attempt, otherwise concurrent logins fight each other and // the plugin ends up in a connect/disconnect storm. if (this.isLoggingIn) { this.log.debug('Login already in progress; skipping duplicate request.'); return this.isConnected; } this.isLoggingIn = true; try { await this.Logout(); // Reset before a fresh attempt so a previous transient failure (e.g. a TLS // negotiation error or a cloud-maintenance outage) does not stick forever. this.isLoginFailed = false; // Prefer the refresh token from a previous session: reconnects happen every // 30 s - 10 min during an outage, and hammering Cognito with password logins // risks throttling/lockout. Fall back to a password login when the refresh // token is missing, expired or revoked. if ((_a = this.aws_tokens) === null || _a === void 0 ? void 0 : _a.refresh_token) { try { this.aws_tokens = await (new jci_hitachi_connections_1.JciHitachiAWSCognitoConnection(this.email, this.password, this.aws_tokens, this.log)).login(true); } catch (e) { this.log.debug(`Refresh-token login failed, falling back to a password login: ${e}`); this.aws_tokens = undefined; } } if (!this.aws_tokens) { this.aws_tokens = await (new jci_hitachi_connections_1.JciHitachiAWSCognitoConnection(this.email, this.password, undefined, this.log)).login(false); } if (!this.aws_tokens) { this.log.info('Login failed'); return false; } this.aws_identity = await (new jci_hitachi_connections_1.GetUser(this.email, this.password, this.aws_tokens, this.log)).get_data(); this.aws_thing_dict = await (new jci_hitachi_connections_1.GetAllDevice(this.aws_tokens, this.log)).get_data(); this.log.debug('aws_identity:' + JSON.stringify(this.aws_identity)); if (this.aws_identity) { this.aws_credentials = await (new jci_hitachi_connections_1.GetCredentials(this.email, this.password, this.aws_tokens, this.log)).get_data(this.aws_identity); this.log.debug(JSON.stringify(this.aws_identity) + ` host_user_id: ${this.aws_identity.host_identity_id}`); this.is_host = this.aws_identity.identity_id === this.aws_identity.host_identity_id; if (this.aws_credentials && this.aws_identity.host_identity_id.length > 0) { // Never dump `this` here: it contains the account password, the // Cognito tokens and the AWS credentials, and debug logs routinely // end up attached to GitHub issues. this.mqttclient = this.createMQTTClient(); } if (this.mqttclient) { const connectionSuccess = (0, events_1.once)(this.mqttclient, 'connectionSuccess'); const connectionFailure = (0, events_1.once)(this.mqttclient, 'connectionFailure'); this.mqttclient.start(); // The mqtt5 client retries internally with the same static SigV4 // credentials, so a failed websocket upgrade (e.g. // AWS_ERROR_HTTP_WEBSOCKET_UPGRADE_FAILURE with stale credentials) // may never succeed. Awaiting connectionSuccess unconditionally used // to hang Login() forever with the isLoggingIn mutex held, blocking // every reconnect attempt until Homebridge was restarted. Race the // outcome instead and fail the login, so the platform backoff // retries with freshly fetched credentials. const connected = await new Promise((resolve) => { const finish = (result) => { clearTimeout(timer); resolve(result); }; const timer = setTimeout(() => { this.log.error(`MQTT connection timed out after ${MQTT_CONNECT_TIMEOUT} ms.`); finish(false); }, MQTT_CONNECT_TIMEOUT); connectionSuccess.then(() => finish(true), () => finish(false)); connectionFailure.then(() => finish(false), () => finish(false)); }); if (!connected) { this.log.error('MQTT connection could not be established; aborting login.'); await this.Logout(); this.isLoginFailed = true; return false; } // Reset the stale-connection marker: after an outage longer than the // 600 s threshold it still holds a pre-outage timestamp, and the next // RefeshDevice poll would flag the fresh connection as timed out and // log out again right away. 0 disables the check until the first // response arrives. this.last_received_time = 0; const suback = await this.mqttclient.subscribe({ subscriptions: [ { qos: jci_hitachi_constants_1.QOS, topicFilter: `${this.aws_identity.host_identity_id}/+/+/response` }, ], }); this.log.debug('Suback result: ' + JSON.stringify(suback)); await this.RefeshAWSThingDictionary('registration'); await this.RefeshAWSThingDictionary('status'); return true; } } } catch (e) { this.log.error(`Login Error: ${e}`); // Surface the real server response so login failures are diagnosable // (e.g. Cognito 400 NotAuthorized / unsupported account, see issue #10). if (axios_1.default.isAxiosError(e) && e.response) { this.log.error(`Login response (HTTP ${e.response.status}): ${JSON.stringify(e.response.data)}`); } this.isLoginFailed = true; } finally { this.isLoggingIn = false; } return false; } async Logout() { var _a; const client = this.mqttclient; const wasConnected = this.isConnected; this.isConnected = false; this.mqttclient = undefined; if (!client) { return true; } // Never await MQTT teardown unbounded: 'disconnection' only fires when the // client was actually connected, and the offline operation queue can park // unsubscribe/stop forever - a hung Logout() also hangs the Login() that // calls it, with the isLoggingIn mutex held (no reconnect ever runs again). if (wasConnected) { try { const unsuback = await withTimeout(client.unsubscribe({ topicFilters: [ `${(_a = this.aws_identity) === null || _a === void 0 ? void 0 : _a.host_identity_id}/#`, ], }), MQTT_TEARDOWN_TIMEOUT, 'MQTT unsubscribe'); this.log.debug('Unsuback result: ' + JSON.stringify(unsuback)); } catch (e) { this.log.debug(`Logout unsubscribe error: ${e}`); } } try { const stopped = (0, events_1.once)(client, 'stopped'); client.stop(); await withTimeout(stopped, MQTT_TEARDOWN_TIMEOUT, 'MQTT stop'); } catch (e) { this.log.debug(`Logout stop error: ${e}`); } try { // Release the native client, otherwise an abandoned instance keeps // retrying its connection (and spamming failures) in the background. client.close(); } catch (e) { this.log.debug(`Logout close error: ${e}`); } return true; } get isHost() { return this.is_host; } getDevices() { return this.aws_thing_dict; } getDevice(thingName) { var _a; return (_a = this.aws_thing_dict) === null || _a === void 0 ? void 0 : _a.getDevice(thingName); } async RefeshAWSThingDictionary(actionName = 'status') { if (!this.aws_thing_dict) { return; } for (const thingName in this.aws_thing_dict.getAllThings()) { this.publish(thingName, actionName); } } async RefeshDevice(thingName) { var _a; if (this.last_received_time !== 0 && Math.ceil(Date.now() / 1000) - this.last_received_time > 600) { this.log.error('MQTT Connection Timeout'); // Funnel through the platform's single backoff reconnect (via the callback) // instead of logging in here, so the stale path doesn't become a second, // competing reconnect (see issue #11). await this.Logout(); if (this.callback) { this.callback(undefined); } return false; } if ((_a = this.aws_thing_dict) === null || _a === void 0 ? void 0 : _a.hasThingName(thingName)) { return await this.publish(thingName, 'status'); } return false; } async GetDeviceStatus(thingName, status_name, need_refresh = false) { var _a; if (need_refresh) { await this.RefeshDevice(thingName); } const device = (_a = this.aws_thing_dict) === null || _a === void 0 ? void 0 : _a.getDevice(thingName); if (!device || device.statusPayload === undefined) { return undefined; } return device.statusPayload[status_name]; } async SetDeviceStatus(thingName, status_name, status_value) { const payload = { 'Condition': { 'ThingName': thingName, 'Index': 0, 'Geofencing': { 'Arrive': null, 'Leave': null, }, }, 'TaskID': this.task_id++, 'Timestamp': Math.ceil(Date.now() / 1000), }; payload[status_name] = status_value; return await this.publish(thingName, 'control', payload); } handleMQTTMessage(topic, payload) { try { const topic_parts = topic.split('/'); const thingName = topic_parts[1]; const actionName = topic_parts[2]; const actionType = topic_parts[3]; const payloadContent = payload ? JSON.parse((0, util_utf8_browser_1.toUtf8)(payload)) : {}; this.log.debug(`Received: ${topic} ${JSON.stringify(payloadContent)}`); if (actionType !== 'response') { return; } if (this.aws_thing_dict === undefined) { return; } this.last_received_time = Math.ceil(Date.now() / 1000); if (this.getDevice(thingName)) { if (actionName === 'status') { this.aws_thing_dict.updateDeviceStatusPayload(thingName, payloadContent); if (this.callback) { this.callback(this.getDevice(thingName)); } } else if (actionName === 'registration') { this.aws_thing_dict.updateDeviceRegistrationPayload(thingName, payloadContent); } else if (actionName === 'control') { this.RefeshDevice(thingName); } } } catch (e) { this.log.error(`MQTT Message Error: ${e}`); } } createMQTTClient() { if (this.aws_credentials === undefined || this.aws_identity === undefined) { throw new Error('aws_credentials is undefined'); } const wsConfig = { credentialsProvider: aws_iot_device_sdk_v2_1.auth.AwsCredentialsProvider.newStatic(this.aws_credentials.access_key_id, this.aws_credentials.secret_access_key, this.aws_credentials.session_token), region: jci_hitachi_constants_1.AWS_REGION, }; const builder = aws_iot_device_sdk_v2_1.iot.AwsIotMqtt5ClientConfigBuilder.newWebsocketMqttBuilderWithSigv4Auth(jci_hitachi_constants_1.AWS_MQTT_ENDPOINT, wsConfig); const clientId = `${this.aws_identity.identity_id}_${generateRandomHex(16)}`; this.log.debug(`clientId: ${clientId}`); builder.withConnectProperties({ keepAliveIntervalSeconds: 120, clientId: `${clientId}` }); const client = new aws_iot_device_sdk_v2_1.mqtt5.Mqtt5Client(builder.build()); client.on('error', (error) => { this.log.error('Error event: ' + error.toString()); this.isConnected = false; this.isLoginFailed = true; }); client.on('messageReceived', (eventData) => { this.handleMQTTMessage(eventData.message.topicName, eventData.message.payload); }); client.on('attemptingConnect', () => { this.log.debug('Attempting Connect event'); }); client.on('connectionSuccess', (eventData) => { this.log.debug('Connection Success event'); this.log.debug('Connack: ' + JSON.stringify(eventData.connack)); this.log.debug('Settings: ' + JSON.stringify(eventData.settings)); this.isConnected = true; }); client.on('connectionFailure', (eventData) => { this.log.error('Connection failure event: ' + eventData.error.toString()); this.isConnected = false; this.isLoginFailed = true; if (this.callback) { this.callback(undefined); } }); client.on('disconnection', (eventData) => { this.log.debug('Disconnection event: ' + eventData.error.toString()); if (eventData.disconnect !== undefined) { this.log.debug('Disconnect packet: ' + JSON.stringify(eventData.disconnect)); } this.isConnected = false; if (this.callback) { this.callback(undefined); } }); client.on('stopped', () => { this.log.debug('Stopped event'); }); return client; } async publish(thingName, request, payload = undefined) { var _a; try { const defaultPayload = JSON.stringify({ 'Timestamp': Math.ceil(Date.now() / 1000) }); if (this.mqttclient && this.isConnected) { const topic = `${(_a = this.aws_identity) === null || _a === void 0 ? void 0 : _a.host_identity_id}/${thingName}`; await this.mqttclient.publish({ qos: jci_hitachi_constants_1.QOS, topicName: `${topic}/${request}/request`, payload: payload ? JSON.stringify(payload) : defaultPayload, }); this.log.debug(`${topic}/${request}/request ${payload ? JSON.stringify(payload) : defaultPayload}`); return true; } } catch (e) { this.log.error(`Publish Error: ${e}`); this.Logout(); if (this.callback) { this.callback(undefined); } } return false; } } exports.default = JciHitachiAWSAPI; //# sourceMappingURL=jci-hitachi-aws-api.js.map