UNPKG

homebridge-econet-rheem

Version:

Homebridge plugin based on pyeconet for control of Rheem water heaters

359 lines 14.1 kB
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