@shadman-a/homebridge-my-ac
Version:
A Homebridge plugin for controlling/monitoring LG ThinQ devices via LG ThinQ platform.
368 lines • 15.5 kB
JavaScript
import { API } from './API.js';
import { Device } from './Device.js';
import { DeviceType, PlatformType } from './constants.js';
import { DeviceModel, ValueType } from './DeviceModel.js';
import { randomUUID } from 'crypto';
import * as Path from 'path';
import * as FS from 'fs';
import forge from 'node-forge';
import Helper from '../v1/helper.js';
import { MonitorError, NotConnectedError } from '../errors/index.js';
import { PLUGIN_NAME } from '../settings.js';
import { device as awsIotDevice } from 'aws-iot-device-sdk';
import { URL } from 'url';
import Persist from './Persist.js';
export class ThinQ {
platform;
config;
logger;
api;
workIds = {};
deviceModel = {};
persist;
constructor(platform, config, logger) {
this.platform = platform;
this.config = config;
this.logger = logger;
this.api = new API(this.config.country, this.config.language, logger);
this.api.httpClient.interceptors.response.use(response => {
this.logger.debug('[request]', response.config.method, response.config.url);
return response;
}, err => {
return Promise.reject(err);
});
if (config.refresh_token) {
this.api.setRefreshToken(config.refresh_token);
}
else if (config.username && config.password) {
this.api.setUsernamePassword(config.username, config.password);
}
this.persist = new Persist(Path.join(this.platform.api.user.storagePath(), PLUGIN_NAME, 'persist', 'devices'));
}
async devices() {
const listDevices = await this.api.getListDevices().catch(() => {
return [];
});
return listDevices.map(device => new Device(device))
// skip all device invalid id
.filter(device => device.id.match(/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/));
}
async setup(device) {
// load device model
device.deviceModel = await this.loadDeviceModel(device);
if (device.deviceModel.data.Monitoring === undefined
&& device.deviceModel.data.MonitoringValue === undefined
&& device.deviceModel.data.Value === undefined) {
this.logger.warn('[' + device.name + '] This device may not "smart" device. Ignore it!');
}
if (device.platform === PlatformType.ThinQ1) {
// register work uuid
await this.registerWorkId(device);
// transform thinq1 device
const deviceWithSnapshot = Helper.transform(device, null);
device.snapshot = deviceWithSnapshot.snapshot;
}
return true;
}
async unregister(device) {
if (device.platform === PlatformType.ThinQ1 && device.id in this.workIds && this.workIds[device.id] !== null) {
try {
await this.api.sendMonitorCommand(device.id, 'Stop', this.workIds[device.id]);
}
catch (err) {
//this.log.error(err);
}
delete this.workIds[device.id];
}
}
async registerWorkId(device) {
return this.workIds[device.id] = await this.api.sendMonitorCommand(device.id, 'Start', randomUUID()).then(data => {
if (data !== undefined && 'workId' in data) {
return data.workId;
}
return null;
});
}
async loadDeviceModel(device) {
let deviceModel = await this.persist.getItem(device.id);
if (!deviceModel) {
this.logger.debug('[' + device.id + '] Device model cache missed.');
deviceModel = await this.api.httpClient.get(device.data.modelJsonUri).then(res => res.data);
await this.persist.setItem(device.id, deviceModel);
}
const modelVersion = parseFloat(deviceModel.Info?.version);
// new washer model
if (device.type === DeviceType[DeviceType.WASH_TOWER_2]
&& modelVersion && modelVersion >= 3
&& deviceModel.Info?.defaultTargetDeviceRoot
&& deviceModel[deviceModel.Info.defaultTargetDeviceRoot]) {
deviceModel = deviceModel[deviceModel.Info.defaultTargetDeviceRoot];
}
return this.deviceModel[device.id] = device.deviceModel = new DeviceModel(deviceModel);
}
async pollMonitor(device) {
device.deviceModel = await this.loadDeviceModel(device);
if (device.platform === PlatformType.ThinQ1) {
let result = null;
// check if work id is registered
if (!(device.id in this.workIds) || this.workIds[device.id] === null) {
// register work id
const workId = await this.registerWorkId(device);
if (workId === undefined || workId === null) { // device may not connected
return Helper.transform(device, result);
}
}
try {
result = await this.api.getMonitorResult(device.id, this.workIds[device.id]);
}
catch (err) {
if (err instanceof MonitorError) {
// restart monitor and try again
await this.unregister(device);
await this.registerWorkId(device);
// retry 1 times
try {
result = await this.api.getMonitorResult(device.id, this.workIds[device.id]);
}
catch (err) {
// stop it
// await this.stopMonitor(device);
}
}
else if (err instanceof NotConnectedError) {
// device not online
// this.log.debug('Device not connected: ', device.toString());
}
else {
throw err;
}
}
return Helper.transform(device, result);
}
return device;
}
thinq1DeviceControl(device, key, value) {
const data = Helper.prepareControlData(device, key, value);
return this.api.thinq1PostRequest('rti/rtiControl', data).catch(err => {
this.logger.error('Unknown Error: ', err);
});
}
async deviceControl(device, values, command = 'Set', ctrlKey = 'basicCtrl', ctrlPath = 'control-sync') {
const id = device instanceof Device ? device.id : device;
const model = this.deviceModel[id];
const coerceValue = (k, v) => {
if (!model) {
return v;
}
try {
const vm = model.value(k);
if (!vm) {
return v;
}
switch (vm.type) {
case ValueType.Bit: {
if (typeof v === 'boolean') {
return v ? 1 : 0;
}
if (typeof v === 'string') {
const n = Number(v);
return Number.isNaN(n) ? (v === '1' ? 1 : 0) : n;
}
return v;
}
case ValueType.Range: {
if (v === null || v === undefined) {
return v;
}
if (typeof v === 'number') {
return v;
}
const nv = Number(v);
return Number.isNaN(nv) ? v : nv;
}
case ValueType.Enum: {
if (typeof v === 'string') {
const enumKey = model.enumValue(k, v);
return enumKey !== null ? enumKey : v;
}
return v;
}
default: {
return v;
}
}
}
catch (e) {
return v;
}
};
if (values && typeof values === 'object') {
if ('dataKey' in values && values.dataKey && 'dataValue' in values) {
try {
values.dataValue = coerceValue(values.dataKey, values.dataValue);
}
catch (e) {
// ignore
}
}
if ('dataSetList' in values && values.dataSetList && typeof values.dataSetList === 'object') {
for (const k of Object.keys(values.dataSetList)) {
values.dataSetList[k] = coerceValue(k, values.dataSetList[k]);
}
}
}
const normalizeBooleans = (obj) => {
if (obj && typeof obj === 'object') {
for (const k of Object.keys(obj)) {
const v = obj[k];
if (typeof v === 'boolean') {
obj[k] = v ? 1 : 0;
}
else if (v && typeof v === 'object') {
normalizeBooleans(v);
}
}
}
};
normalizeBooleans(values);
const response = await this.api.sendCommandToDevice(id, values, command, ctrlKey, ctrlPath);
if (response.resultCode === '0000') {
this.logger.debug('ThinQ Device Received the Command');
return true;
}
else {
this.logger.debug('ThinQ Device Did Not Received the Command');
return false;
}
}
async registerMQTTListener(callback) {
const delayMs = (ms) => new Promise(res => setTimeout(res, ms));
let tried = 5;
while (tried > 0) {
try {
await this._registerMQTTListener(callback);
return;
}
catch (err) {
tried--;
this.logger.debug('Cannot start MQTT, retrying in 5s.');
this.logger.debug('mqtt err:', err);
await delayMs(5000);
}
}
this.logger.error('Cannot start MQTT!');
}
async _registerMQTTListener(callback) {
const route = await this.api.getRequest('https://common.lgthinq.com/route').then(data => data.result);
// key-pair
const keys = await this.persist.cacheForever('keys', async () => {
this.logger.debug('Generating 2048-bit key-pair...');
const keys = forge.pki.rsa.generateKeyPair(2048);
return {
privateKey: forge.pki.privateKeyToPem(keys.privateKey),
publicKey: forge.pki.publicKeyToPem(keys.publicKey),
};
});
// CSR
const csr = await this.persist.cacheForever('csr', async () => {
this.logger.debug('Creating certification request (CSR)...');
const csr = forge.pki.createCertificationRequest();
csr.publicKey = forge.pki.publicKeyFromPem(keys.publicKey);
csr.setSubject([
{
shortName: 'CN',
value: 'AWS IoT Certificate',
},
{
shortName: 'O',
value: 'Amazon',
},
]);
csr.sign(forge.pki.privateKeyFromPem(keys.privateKey), forge.md.sha256.create());
return forge.pki.certificationRequestToPem(csr);
});
const submitCSR = async () => {
await this.api.postRequest('service/users/client', {});
return await this.api.postRequest('service/users/client/certificate', {
csr: csr.replace(/-----(BEGIN|END) CERTIFICATE REQUEST-----/g, '').replace(/(\r\n|\r|\n)/g, ''),
}).then(data => data.result);
};
const urls = new URL(route.mqttServer);
// get trusted cer root based on hostname
let rootCAUrl;
if (urls.hostname.match(/^([^.]+)-ats.iot.([^.]+).amazonaws.com$/g)) {
// ats endpoint
rootCAUrl = 'https://www.amazontrust.com/repository/AmazonRootCA1.pem';
}
else if (urls.hostname.match(/^([^.]+).iot.ruic.lgthinq.com$/g)) {
// LG owned certificate - Comodo CA
rootCAUrl = 'http://www.tbs-x509.com/Comodo_AAA_Certificate_Services.crt';
}
else {
// use legacy VeriSign cert for other endpoint
// eslint-disable-next-line max-len
rootCAUrl = 'https://www.websecurity.digicert.com/content/dam/websitesecurity/digitalassets/desktop/pdfs/roots/VeriSign-Class%203-Public-Primary-Certification-Authority-G5.pem';
}
const rootCA = await this.api.getRequest(rootCAUrl);
const connectToMqtt = async () => {
// submit csr
const certificate = await submitCSR();
const mqttDir = Path.join(this.platform.api.user.storagePath(), PLUGIN_NAME, 'persist', 'mqtt');
await FS.promises.mkdir(mqttDir, { recursive: true });
const caPath = Path.join(mqttDir, 'ca.pem');
const keyPath = Path.join(mqttDir, 'key.pem');
const certPath = Path.join(mqttDir, 'cert.pem');
const writeIfChanged = async (p, content) => {
try {
const existing = await FS.promises.readFile(p, 'utf8').catch(() => null);
if (existing !== content) {
await FS.promises.writeFile(p, content, 'utf8');
}
}
catch (err) {
await FS.promises.writeFile(p, content, 'utf8');
}
};
await writeIfChanged(caPath, rootCA);
await writeIfChanged(keyPath, keys.privateKey);
await writeIfChanged(certPath, certificate.certificatePem);
const connectData = {
caPath,
keyPath,
certPath,
clientId: this.api.client_id,
host: urls.hostname,
};
this.logger.debug('open mqtt connection to', route.mqttServer);
const device = new awsIotDevice(connectData);
device.on('error', (err) => {
this.logger.error('mqtt err:', err);
});
device.on('connect', () => {
this.logger.info('Successfully connected to the MQTT server.');
this.logger.debug('mqtt connected:', route.mqttServer);
for (const subscription of certificate.subscriptions) {
device.subscribe(subscription);
}
});
device.on('message', (topic, payload) => {
callback(JSON.parse(payload.toString()));
this.logger.debug('mqtt message received:', payload.toString());
});
device.on('offline', () => {
device.end();
this.logger.info('MQTT disconnected, retrying in 60 seconds!');
setTimeout(async () => {
await connectToMqtt();
}, 60000);
});
};
// first call
await connectToMqtt();
}
async isReady() {
await this.persist.init();
await this.api.ready();
}
}
//# sourceMappingURL=ThinQ.js.map