homebridge-econet-rheem
Version:
Homebridge plugin based on pyeconet for control of Rheem water heaters
230 lines • 8.54 kB
JavaScript
import mqtt from 'mqtt';
import { WaterHeater } from './waterHeater.js';
import { Thermostat } from './thermostat.js';
const HOST = 'rheem.clearblade.com';
const REST_URL = `https://${HOST}/api/v/1`;
const CLEAR_BLADE_SYSTEM_KEY = 'e2e699cb0bb0bbb88fc8858cb5a401';
const CLEAR_BLADE_SYSTEM_SECRET = 'E2E699CB0BE6C6FADDB1B0BC9A20';
const HEADERS = {
'ClearBlade-SystemKey': CLEAR_BLADE_SYSTEM_KEY,
'ClearBlade-SystemSecret': CLEAR_BLADE_SYSTEM_SECRET,
'Content-Type': 'application/json; charset=UTF-8',
};
export const WATER_HEATER = 'WH';
export const THERMOSTAT = 'HVAC';
export class PyeconetError extends Error {
constructor(message) {
super(message);
this.name = 'PyeconetError';
}
}
export class InvalidCredentialsError extends PyeconetError {
constructor(message) {
super(message);
this.name = 'InvalidCredentialsError';
}
}
export class InvalidResponseFormat extends PyeconetError {
constructor(message) {
super(message);
this.name = 'InvalidResponseFormat';
}
}
export class GenericHTTPError extends PyeconetError {
constructor(message) {
super(message);
this.name = 'GenericHTTPError';
}
}
export class EconetApi {
log;
email;
verbose;
_userToken = null;
_accountId = null;
_equipment = new Map();
_mqttClient = null;
constructor(log, email, verbose) {
this.log = log;
this.email = email;
this.verbose = verbose;
}
get userToken() {
return this._userToken;
}
get accountId() {
return this._accountId;
}
static async login(log, email, password, verbose) {
const api = new EconetApi(log, email, verbose);
await api._authenticate({ email, password });
return api;
}
async _authenticate(payload) {
const response = await fetch(`${REST_URL}/user/auth`, {
method: 'POST',
headers: HEADERS,
body: JSON.stringify(payload),
});
if (response.status === 200) {
const json = await response.json();
if (json.options.success) {
this._userToken = json.user_token;
this._accountId = json.options.account_id;
}
else {
throw new InvalidCredentialsError(json.options.message || 'Invalid credentials');
}
}
else {
throw new GenericHTTPError(`HTTP error: ${response.status}`);
}
}
_getClientId() {
const timeString = Date.now().toString().replace('.', '').slice(0, 13);
return `${this.email}${timeString}_android`;
}
subscribe() {
if (!this._equipment.size) {
return;
}
const clientId = this._getClientId();
const connectionOptions = {
clientId,
username: this._userToken,
password: CLEAR_BLADE_SYSTEM_KEY,
rejectUnauthorized: true,
keepalive: 90,
};
this._mqttClient = mqtt.connect(`mqtts://${HOST}:1884`, connectionOptions);
this._mqttClient.on('connect', () => {
this._mqttClient.subscribe(`user/${this._accountId}/device/reported`);
this._mqttClient.subscribe(`user/${this._accountId}/device/desired`);
});
this._mqttClient.on('message', (topic, message) => {
try {
const unpackedJson = JSON.parse(message.toString());
if (this.verbose) {
this.log.debug(`Received MQTT message from topic: ${topic}\n`, JSON.stringify(unpackedJson, null, 2));
}
const serial = unpackedJson.serial_number;
const equipment = this._equipment.get(serial);
if (equipment) {
equipment.updateFromMQTT(unpackedJson);
}
else if ('@SIGNAL' in unpackedJson) {
for (const eq of this._equipment.values()) {
if (eq.deviceId === unpackedJson.device_name) {
eq.updateFromMQTT(unpackedJson);
}
}
}
}
catch (e) {
this.log.error('Failed to parse MQTT message:', message.toString());
}
});
this._mqttClient.on('offline', () => {
this.log.debug('MQTT client went offline');
});
let reconnectAttempts = 1;
this._mqttClient.on('close', () => {
this.log.debug('MQTT connection closed, attempting to reconnect...');
setTimeout(() => {
this._mqttClient.reconnect(connectionOptions);
reconnectAttempts *= 2;
}, Math.min(300000, 5000 * reconnectAttempts)); // Exponential backoff: 5s, 10s, 20s... 300s
});
this._mqttClient.on('error', (err) => {
this.log.error('MQTT error:', err);
});
}
publish(payload, deviceId, serialNumber) {
const dateTime = new Date().toISOString().replace(/\.\d{3}Z$/, '');
const transactionId = `ANDROID_${dateTime}`;
const publishPayload = {
transactionId,
device_name: deviceId,
serial_number: serialNumber,
...payload,
};
if (this._mqttClient) {
const topic = `user/${this._accountId}/device/desired`;
const payload = JSON.stringify(publishPayload, null, 2);
this.log.debug(`Publishing MQTT message to topic: ${topic}\n`, payload);
this._mqttClient.publish(topic, payload);
}
}
unsubscribe() {
if (this._mqttClient) {
this._mqttClient.end();
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async _getLocation() {
const headers = { ...HEADERS, 'ClearBlade-UserToken': this._userToken };
const payload = {
location_only: false,
type: 'com.econet.econetconsumerandroid',
version: '6.0.0-375-01b4870e',
};
const response = await fetch(`${REST_URL}/code/${CLEAR_BLADE_SYSTEM_KEY}/getUserDataForApp`, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
if (response.status === 200) {
const json = await response.json();
if (json.success) {
return json.results.locations;
}
throw new InvalidResponseFormat('Invalid response format');
}
throw new GenericHTTPError(`HTTP error: ${response.status}`);
}
async getEquipment() {
const locations = await this._getLocation();
for (const location of locations) {
for (const equip of location.equiptments) {
if ('error' in equip) {
this.log.error(`econet equipment error: ${equip.error}`);
continue;
}
if (this.verbose) {
this.log.debug('Creating Equipment with data:\n', JSON.stringify(equip, null, 2));
}
let equipObj;
if (equip.device_type === WATER_HEATER) {
equipObj = new WaterHeater(this, equip);
}
else if (equip.device_type === THERMOSTAT) {
equipObj = new Thermostat(this, equip);
}
else {
continue;
}
this._equipment.set(equipObj.serialNumber, equipObj);
if (equip.device_type === THERMOSTAT && equip.zoning_devices) {
for (const zoningDevice of equip.zoning_devices) {
const zoningEquip = new Thermostat(this, zoningDevice);
this._equipment.set(zoningEquip.serialNumber, zoningEquip);
}
}
}
}
}
async getEquipmentByType(types) {
if (!this._equipment.size) {
await this.getEquipment();
}
const result = new Map();
types.forEach((type) => result.set(type, []));
for (const equip of this._equipment.values()) {
if (types.includes(equip instanceof WaterHeater ? WATER_HEATER : THERMOSTAT)) {
result.get(equip instanceof WaterHeater ? WATER_HEATER : THERMOSTAT).push(equip);
}
}
return result;
}
}
//# sourceMappingURL=econet.js.map