homebridge-econet-rheem
Version:
Homebridge plugin based on pyeconet for control of Rheem water heaters
359 lines • 14.1 kB
JavaScript
import axios, { isAxiosError } from 'axios';
import mqtt from 'mqtt';
import { Auth } from './auth.js';
import { EquipmentType } from './constants.js';
import * as Types from './types.js';
import { Thermostat } from './thermostat.js';
import { WaterHeater } from './waterHeater.js';
import strings from '../lang/en.js';
import { safeGetItem, safeSetItem, STORAGE_KEY_MQTT } from '../tools/storage.js';
import { MINUTE, SECOND } from '../tools/time.js';
import { LogType } from '../tools/log.js';
const CLEAR_BLADE_SYSTEM_KEY = 'e2e699cb0bb0bbb88fc8858cb5a401';
const CLEAR_BLADE_SYSTEM_SECRET = 'E2E699CB0BE6C6FADDB1B0BC9A20';
const BASE_HEADERS = {
'ClearBlade-SystemKey': CLEAR_BLADE_SYSTEM_KEY,
'ClearBlade-SystemSecret': CLEAR_BLADE_SYSTEM_SECRET,
'Content-Type': 'application/json; charset=UTF-8',
};
const HOST = 'rheem.clearblade.com';
const BASE_URL = `https://${HOST}/api/v/1`;
const AUTH_URL = `${BASE_URL}/user/auth`;
const LOCATIONS_URL = `${BASE_URL}/code/${CLEAR_BLADE_SYSTEM_KEY}/getUserDataForApp`;
const MQTT_URL = `mqtts://${HOST}:1884`;
const MQTT_TOPIC_REPORTED = 'user/%s/device/reported';
const MQTT_TOPIC_DESIRED = 'user/%s/device/desired';
const HTTP_TIMEOUT = 10 * SECOND;
const MQTT_KEEPALIVE = 90;
const DELAYS = [5 * SECOND, 15 * SECOND, MINUTE, 2 * MINUTE, 5 * MINUTE];
const IDLE_CONNECTION_TIMER_INTERVAL = 16 * MINUTE;
const HTTP_RETRY_CODES = [
'ERR_NETWORK', // General network error in Axios
'ETIMEDOUT', // Request timed out
'ECONNREFUSED', // Connection refused by server
'429', // Too Many Requests (rate limit)
'500', // Internal Server Error
'502', // Bad Gateway
'503', // Service Unavailable
'504', // Gateway Timeout
];
export class EconetApi {
log;
email;
password;
storageFilePath;
debugMQTT;
_auth;
retryIndex = 0;
equipments = new Map();
mqttClient = null;
shouldReconnect = false;
isReconnecting = false;
reconnectCount = 0;
idleMQTTTimer = null;
constructor(log, email, password, storageFilePath, debugMQTT) {
this.log = log;
this.email = email;
this.password = password;
this.storageFilePath = storageFilePath;
this.debugMQTT = debugMQTT;
this.auth = Auth.load(this.storageFilePath, email);
}
static async connect(log, email, password, storageFilePath, debugMQTT) {
const api = new EconetApi(log, email, password, storageFilePath, debugMQTT);
let shouldContinue = true;
if (!api.auth) {
shouldContinue = await api.authenticate();
}
if (shouldContinue) {
await api.getLocations();
api.shouldReconnect = true;
api.mqttConnect(true);
}
return api;
}
teardown() {
this.shouldReconnect = false;
if (this.mqttClient) {
this.mqttClient.end(true);
this.mqttClient = null;
}
}
publish(payload, deviceId, serialNumber) {
if (!this.mqttClient || !this.mqttClient.connected) {
this.log.error(strings.clientNotConnected);
return;
}
if (!this.auth?.accountId) {
this.log.error(strings.authMissing);
return;
}
const dateTime = new Date().toISOString().replace(/\.\d{3}Z$/, '');
const transactionId = `ANDROID_${dateTime}`;
const data = {
transactionId,
device_name: deviceId,
serial_number: serialNumber,
...payload,
};
const topic = MQTT_TOPIC_DESIRED.replace('%s', this.auth.accountId);
const message = JSON.stringify(data);
this.mqttClient.publish(topic, message);
this.logDebug(this.publish.name, topic, data);
}
get auth() {
return this._auth ?? null;
}
set auth(value) {
this._auth = value;
if (this._auth) {
this._auth.save(this.storageFilePath, this.email);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async httpRequest(caller,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data, url, ...parameters) {
parameters.forEach(param => {
url = url.replace('%s', param ?? '');
});
let config;
if (this.auth?.token) {
const headers = { ...BASE_HEADERS, 'ClearBlade-UserToken': this.auth?.token };
config = { headers: headers, timeout: HTTP_TIMEOUT };
}
else {
config = { headers: BASE_HEADERS, timeout: HTTP_TIMEOUT };
}
try {
let res;
if (data) {
res = await axios.post(url, data, config);
}
else {
res = await axios.get(url, config);
}
if (!res.data) {
this.log.warning(caller, this.desensitize(res.data));
throw new Error(strings.noDataReceived);
}
this.logDebug(caller, url.substring(BASE_URL.length + 1), res.data);
this.retryIndex = 0;
return res.data;
}
catch (err) {
return this.retryHTTPIfPossible(err, caller, () => this.httpRequest(caller, data, url, ...parameters));
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async retryHTTPIfPossible(err, caller, retry) {
if (!isAxiosError(err)) {
this.log.warning(err.message);
return null;
}
const errorCode = err.code || err.response?.status?.toString() || 'UNKNOWN';
if (!HTTP_RETRY_CODES.includes(errorCode) || this.retryIndex >= DELAYS.length) {
this.log.warning(err.message);
return null;
}
const retryDelay = DELAYS[Math.min(this.retryIndex, DELAYS.length - 1)];
if (retryDelay <= MINUTE) {
this.log.ifVerbose(strings.httpRetrySeconds, retryDelay / SECOND);
}
else {
this.log.ifVerbose(strings.httpRetryMinutes, retryDelay / MINUTE);
}
await new Promise(resolve => setTimeout(resolve, retryDelay));
this.retryIndex += 1;
return await retry();
}
async authenticate() {
const data = { email: this.email, password: this.password };
const tokenData = await this.httpRequest(this.authenticate.name, data, AUTH_URL);
if (!tokenData) {
return false;
}
this.auth = new Auth(tokenData);
this.log.always(strings.authSuccess);
return true;
}
mqttConnect(isStartup = false) {
if (!this.equipments.size) {
return;
}
if (!this.auth?.token) {
this.log.error(strings.authMissing);
return;
}
const timeString = Date.now().toString().replace('.', '').slice(0, 13);
const clientId = `${this.email}${timeString}_android`;
const options = {
clientId,
username: this.auth.token,
password: CLEAR_BLADE_SYSTEM_KEY,
rejectUnauthorized: true,
keepalive: MQTT_KEEPALIVE,
reconnectPeriod: 0,
};
this.mqttClient = mqtt.connect(MQTT_URL, options);
this.mqttClient.on('connect', () => this.mqttSubscribe(isStartup));
this.mqttClient.on('message', (topic, message) => this.mqttMessageReceived(topic, message.toString()));
this.mqttClient.on('close', () => this.mqttConnectionClosed());
this.mqttClient.on('error', (error) => this.log.ifVerbose(LogType.WARNING, strings.clientError, error));
}
mqttSubscribe(isStartup) {
if (!this.mqttClient || !this.auth?.accountId) {
this.log.error(strings.connectionError);
return;
}
this.mqttClient.subscribe(MQTT_TOPIC_REPORTED.replace('%s', this.auth.accountId));
this.mqttClient.subscribe(MQTT_TOPIC_DESIRED.replace('%s', this.auth.accountId));
this.log.always(strings.connected);
if (isStartup) {
const welcomeMessages = [strings.welcomeMessage_1, strings.welcomeMessage_2, strings.welcomeMessage_3, strings.welcomeMessage_4];
const randIndex = Math.floor(Math.random() * welcomeMessages.length);
this.log.always(strings.setupComplete, welcomeMessages[randIndex]);
}
}
mqttMessageReceived(topic, message) {
this.reconnectCount = 0;
this.resetIdleMQTTTimer();
try {
const data = JSON.parse(message);
const equipment = data.serial_number ? this.equipments.get(data.serial_number) : null;
if (equipment) {
this.logDebug(this.mqttMessageReceived.name, topic, data);
equipment.updateFromMQTT(data);
if (this.debugMQTT) {
this.saveMQTT(data);
}
}
}
catch (e) {
this.log.warning(strings.parseFailed, this.desensitize(message));
}
}
mqttConnectionClosed() {
this.log.ifVerbose(strings.connectionClosed);
this.mqttReconnect();
}
resetIdleMQTTTimer() {
if (this.idleMQTTTimer) {
clearTimeout(this.idleMQTTTimer);
}
this.idleMQTTTimer = setTimeout(() => {
this.log.ifVerbose(strings.idleConnection);
this.mqttReconnect();
}, IDLE_CONNECTION_TIMER_INTERVAL);
}
async mqttReconnect() {
if (!this.shouldReconnect || this.isReconnecting) {
return;
}
this.isReconnecting = true;
if (this.mqttClient) {
this.mqttClient.end(true);
this.mqttClient = null;
}
this.reconnectCount++;
if (this.reconnectCount % DELAYS.length === 0) {
try {
this.log.ifVerbose(strings.unstableConnection);
this.log.ifVerbose(strings.reauthenticate);
await this.authenticate();
}
catch (error) {
this.log.ifVerbose(strings.reauthFailed, error);
}
}
const reconnectDelay = DELAYS[Math.min(this.reconnectCount, DELAYS.length - 1)];
if (reconnectDelay < MINUTE) {
this.log.ifVerbose(strings.reconnectInSeconds, reconnectDelay / SECOND);
}
else {
this.log.ifVerbose(strings.reconnectInMinutes, reconnectDelay / MINUTE);
}
setTimeout(() => {
this.isReconnecting = false;
this.mqttConnect();
}, reconnectDelay);
}
async getLocations() {
const data = {
location_only: false,
type: 'com.econet.econetconsumerandroid',
version: '6.0.0-375-01b4870e',
};
const locationsData = await this.httpRequest(this.getLocations.name, data, LOCATIONS_URL);
if (!locationsData) {
return;
}
locationsData.results.locations.forEach(location => {
location.equiptments.forEach(equipmentData => {
let equipment = null;
switch (equipmentData.device_type) {
case EquipmentType.THERMOSTAT:
equipment = new Thermostat(this, equipmentData);
break;
case EquipmentType.WATER_HEATER:
equipment = new WaterHeater(this, equipmentData, this.storageFilePath);
break;
default:
this.log.error(strings.unsupportedEquipment, equipmentData.device_type);
}
if (equipment) {
this.equipments.set(equipment.serialNumber, equipment);
if (equipmentData.device_type === EquipmentType.THERMOSTAT && equipmentData.zoning_devices) {
equipmentData.zoning_devices.forEach(zoningEquipmentData => {
const zoningEquip = new Thermostat(this, zoningEquipmentData);
this.equipments.set(zoningEquip.serialNumber, zoningEquip);
});
}
}
});
});
}
saveMQTT(data) {
const ignoreKeys = new Set(['transactionId', 'device_name', 'serial_number']);
const objectString = safeGetItem(this.storageFilePath, STORAGE_KEY_MQTT);
const valuesObject = objectString ? JSON.parse(objectString) : {};
for (const [key, value] of Object.entries(data)) {
if (ignoreKeys.has(key) || value.toString.length === 0) {
continue;
}
let valuesArray = valuesObject[key] ?? [];
const valuesSet = new Set(valuesArray);
valuesSet.add(value);
valuesArray = Array.from(valuesSet);
valuesObject[key] = valuesArray;
}
safeSetItem(this.storageFilePath, STORAGE_KEY_MQTT, JSON.stringify(valuesObject));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
logDebug(caller, message, data) {
if (this.log.verbose) {
this.log.ifVerbose(`${caller}() —`, this.desensitize(message), `\n${this.desensitize(data)}`);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
desensitize(data) {
let output;
if (typeof data === 'string') {
output = data;
}
else {
output = JSON.stringify(data);
Types.SENSITIVE_KEYS.forEach(key => {
const regex = new RegExp(`"${key}"\\s*:\\s*(".*?"|\\d+|true|false|null)`, 'gi');
output = output.replace(regex, `"${key}": "${strings.redacted}"`);
});
}
if (this.auth) {
output = output.replaceAll(this.auth.accountId, strings.redacted);
output = output.replaceAll(this.auth.token, strings.redacted);
output = output.replaceAll(this.auth.userId, strings.redacted);
}
return output;
}
}
//# sourceMappingURL=api.js.map