homebridge-jci-hitachi-platform
Version:
Homebridge platform plugin providing HomeKit support for Jci Hitachi air conditioners.
387 lines • 18.1 kB
JavaScript
"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