UNPKG

@shadman-a/homebridge-my-ac

Version:

A Homebridge plugin for controlling/monitoring LG ThinQ devices via LG ThinQ platform.

355 lines 14 kB
import * as constants from './constants.js'; import { URL } from 'url'; import { Session } from './Session.js'; import { Gateway } from './Gateway.js'; import { requestClient } from './request.js'; import { Auth } from './Auth.js'; import { ManualProcessNeeded, MonitorError, NotConnectedError, TokenExpiredError } from '../errors/index.js'; import crypto from 'crypto'; import axios from 'axios'; /** * The `API` class provides methods to interact with the LG ThinQ API, enabling * device management, home management, and command execution. It handles * authentication, session management, and API requests with appropriate headers. * * @remarks * This class includes methods for sending commands to devices, retrieving device * and home information, and managing authentication tokens. It supports both * ThinQ1 and ThinQ2 APIs. * * @example * ```typescript * const api = new API('US', 'en-US', logger); * api.setUsernamePassword('username', 'password'); * await api.ready(); * const devices = await api.getListDevices(); * console.log(devices); * ``` * * @param country - The country code (default: 'US'). * @param language - The language code (default: 'en-US'). * @param logger - The logger instance for logging debug and error messages. */ export class API { country; language; logger; _homes; _gateway; session = new Session('', '', 0); jsessionId; auth; userNumber; username; password; client_id; httpClient = requestClient; constructor(country = 'US', language = 'en-US', logger) { this.country = country; this.language = language; this.logger = logger; } /** * Sends a GET request to the specified URI. * * @param uri - The URI to send the GET request to. * @returns A promise resolving to the response data. * @throws Error if the URI is invalid. */ async getRequest(uri) { if (typeof uri !== 'string' || !uri.trim()) { this.logger.error('Invalid URI: ', uri); throw new Error('Invalid URI: URI must be a non-empty string.'); } return await this.request('get', uri); } /** * Sends a POST request to the specified URI with the provided data. * * @param uri - The URI to send the POST request to. * @param data - The data to include in the POST request. * @returns A promise resolving to the response data. */ async postRequest(uri, data) { return await this.request('post', uri, data); } resolveUrl(from, to) { const url = new URL(to, from); return url.href; } /** * Sends an HTTP request to the ThinQ API. * * @param method - The HTTP method ('get' or 'post'). * @param uri - The URI to send the request to. * @param data - Optional data to include in the request. * @param retry - Whether to retry the request in case of token expiration. * @returns A promise resolving to the response data. */ async request(method, uri, data, retry = false) { const gateway = await this.gateway(); // Determine the appropriate headers based on the URI const requestHeaders = (gateway.thinq1_url && uri.startsWith(gateway.thinq1_url)) ? this.monitorHeaders : this.defaultHeaders; const url = this.resolveUrl(gateway.thinq2_url, uri); return await this.httpClient.request({ method, url, data, headers: requestHeaders, }).then(res => res.data).catch(async (err) => { // Handle token expiration and retry the request if (err instanceof TokenExpiredError && !retry) { return await this.refreshNewToken().then(async () => { return await this.request(method, uri, data, true); }).catch((err) => { this.logger.error('refresh new token error: ', err); return {}; }); } else if (err instanceof ManualProcessNeeded) { // Handle manual process errors (e.g., new terms agreement) this.logger.warn('Handling new term agreement... If you keep getting this message, ' + err.message); await this.auth.handleNewTerm(this.session.accessToken) .then(() => { this.logger.warn('LG new term agreement is accepted.'); }) .catch(err => { this.logger.error(err); }); if (!retry) { // Retry the request once return await this.request(method, uri, data, true); } else { return {}; } } else { // Log other errors if (axios.isAxiosError(err)) { this.logger.error('axios request error: ', err.response?.data, data); this.logger.error(err.stack || 'No stack error'); } else if (!(err instanceof NotConnectedError)) { this.logger.error('request error: ', err); } return {}; } }); } get monitorHeaders() { const monitorHeaders = { 'Accept': 'application/json', 'x-thinq-application-key': 'wideq', 'x-thinq-security-key': 'nuts_securitykey', }; if (typeof this.session?.accessToken === 'string') { monitorHeaders['x-thinq-token'] = this.session?.accessToken; } if (this.jsessionId) { monitorHeaders['x-thinq-jsessionId'] = this.jsessionId; } return monitorHeaders; } get defaultHeaders() { function random_string(length) { const result = []; const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const charactersLength = characters.length; for (let i = 0; i < length; i++) { result.push(characters.charAt(Math.floor(Math.random() * charactersLength))); } return result.join(''); } const headers = {}; if (this.session.accessToken) { headers['x-emp-token'] = this.session.accessToken; } if (this.userNumber) { headers['x-user-no'] = this.userNumber; } headers['x-client-id'] = this.client_id || constants.API_CLIENT_ID; return { 'x-api-key': constants.API_KEY, 'x-thinq-app-ver': '3.6.1200', 'x-thinq-app-type': 'NUTS', 'x-thinq-app-level': 'PRD', 'x-thinq-app-os': 'ANDROID', 'x-thinq-app-logintype': 'LGE', 'x-service-code': 'SVC202', 'x-country-code': this.country, 'x-language-code': this.language, 'x-service-phase': 'OP', 'x-origin': 'app-native', 'x-model-name': 'samsung/SM-G930L', 'x-os-version': 'AOS/7.1.2', 'x-app-version': 'LG ThinQ/3.6.12110', 'x-message-id': random_string(22), 'user-agent': 'okhttp/3.14.9', ...headers, }; } async getSingleDevice(device_id) { return await this.getRequest('service/devices/' + device_id).then(data => data.result); } /** * Retrieves the list of devices associated with the user's account. * * @returns A promise resolving to an array of devices. */ async getListDevices() { const homes = await this.getListHomes(); const devices = []; // Retrieve devices for each home for (let i = 0; i < homes.length; i++) { const resp = await this.getRequest('service/homes/' + homes[i].homeId); devices.push(...resp.result.devices); } return devices; } /** * Retrieves the list of homes associated with the user's account. * * @returns A promise resolving to an array of homes. */ async getListHomes() { if (!this._homes) { this._homes = await this.getRequest('service/homes').then(data => data.result.item); } return this._homes; } /** * Sends a command to a specific device. * * @param device_id - The ID of the device to send the command to. * @param values - The command values to send. * @param command - The type of command ('Set' or 'Operation'). * @param ctrlKey - The control key (default: 'basicCtrl'). * @param ctrlPath - The control path (default: 'control-sync'). * @returns A promise resolving to the response of the command. * @throws Error if `device_id` is not a valid non-empty string. */ async sendCommandToDevice(device_id, values, command, ctrlKey = 'basicCtrl', ctrlPath = 'control-sync') { if (typeof device_id !== 'string' || !device_id.trim()) { throw new Error('Invalid device_id: must be a non-empty string.'); } if (typeof command !== 'string' || !['Set', 'Operation'].includes(command)) { throw new Error('Invalid command: must be "Set" or "Operation".'); } return await this.postRequest('service/devices/' + device_id + '/' + ctrlPath, { ctrlKey, 'command': command, ...values, }); } /** * Sends a monitor command to a specific device. * * @param deviceId - The ID of the device to monitor. * @param cmdOpt - The command option for monitoring. * @param workId - The work ID associated with the monitoring command. * @returns A promise resolving to the response of the monitor command. * @throws Error if `deviceId` or `cmdOpt` is not a valid non-empty string. */ async sendMonitorCommand(deviceId, cmdOpt, workId) { if (typeof deviceId !== 'string' || !deviceId.trim()) { throw new Error('Invalid deviceId: must be a non-empty string.'); } if (typeof cmdOpt !== 'string' || !cmdOpt.trim()) { throw new Error('Invalid cmdOpt: must be a non-empty string.'); } const data = { cmd: 'Mon', cmdOpt, deviceId, workId, }; return await this.thinq1PostRequest('rti/rtiMon', data); } /** * Retrieves the monitor result for a specific device and work ID. * * @param device_id - The ID of the device. * @param work_id - The work ID associated with the monitor result. * @returns A promise resolving to the monitor result or null if not available. * @throws Error if `device_id` or `work_id` is not a valid non-empty string. */ async getMonitorResult(device_id, work_id) { if (typeof device_id !== 'string' || !device_id.trim()) { throw new Error('Invalid device_id: must be a non-empty string.'); } if (typeof work_id !== 'string' || !work_id.trim()) { throw new Error('Invalid work_id: must be a non-empty string.'); } return await this.thinq1PostRequest('rti/rtiResult', { workList: [{ deviceId: device_id, workId: work_id }] }) .then(data => { if (!('workList' in data) || !('returnCode' in data.workList)) { return null; } const workList = data.workList; if (workList.returnCode !== '0000') { throw new MonitorError(data); } if (!('returnData' in workList)) { return null; } return Buffer.from(workList.returnData, 'base64'); }); } setRefreshToken(refreshToken) { if (typeof refreshToken !== 'string' || !refreshToken.trim()) { throw new Error('Invalid refreshToken: refreshToken must be a non-empty string.'); } this.session = new Session('', refreshToken, 0); } setUsernamePassword(username, password) { this.username = username; this.password = password; } async gateway() { if (!this._gateway) { const gateway = await requestClient.get(constants.GATEWAY_URL, { headers: this.defaultHeaders }).then(res => res.data.result); this._gateway = new Gateway(gateway); } return this._gateway; } async ready() { // get gateway first const gateway = await this.gateway(); if (!this.auth) { this.auth = new Auth(gateway, this.logger); this.auth.logger = this.logger; } if (!this.session.hasToken() && this.username && this.password) { this.session = await this.auth.login(this.username, this.password); await this.refreshNewToken(this.session); } if (!this.session.hasValidToken() && !!this.session.refreshToken) { await this.refreshNewToken(this.session); } if (!this.jsessionId) { // get new jsessionid this.jsessionId = await this.auth.getJSessionId(this.session.accessToken); } if (!this.userNumber) { this.userNumber = await this.auth.getUserNumber(this.session?.accessToken); } if (!this.client_id) { const hash = crypto.createHash('sha256'); this.client_id = hash.update(this.userNumber + (new Date()).getTime()).digest('hex'); } } async refreshNewToken(session = null) { session = session || this.session; this.session = await this.auth.refreshNewToken(session); this.jsessionId = await this.auth.getJSessionId(this.session.accessToken); } async thinq1PostRequest(endpoint, data) { return await this.postRequest(this._gateway?.thinq1_url + endpoint, { lgedmRoot: data, }).then(data => data.lgedmRoot); } } //# sourceMappingURL=API.js.map