UNPKG

homebridge-lg-thinq-ceiling-fan

Version:

A Homebridge plugin for controlling LG ceiling fans via the LG ThinQ platform with working speed control

681 lines (680 loc) 34.8 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.LGApi = void 0; const axios_1 = __importDefault(require("axios")); const luxon_1 = require("luxon"); const crypto = __importStar(require("crypto")); const qs = __importStar(require("querystring")); const settings_1 = require("./settings"); class LGApi { constructor(country = settings_1.LG_API_CONSTANTS.DEFAULT_COUNTRY, language = settings_1.LG_API_CONSTANTS.DEFAULT_LANGUAGE) { this.authData = null; this.gatewayInfo = null; this.tokenObtainedAt = null; this.lastApiError = null; this.consecutiveErrors = 0; this.circuitBreakerOpen = false; this.country = country; this.language = language; this.axiosInstance = axios_1.default.create({ timeout: 30000, }); } generateRandomString(length) { const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let result = ''; for (let i = 0; i < length; i++) { result += characters.charAt(Math.floor(Math.random() * characters.length)); } return result; } createSignature(message, secret) { return crypto.createHmac('sha1', secret).update(message).digest('base64'); } async getGatewayInfo() { try { const headers = { 'x-api-key': 'VGhpblEyLjAgU0VSVklDRQ==', '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': this.generateRandomString(22), 'user-agent': 'okhttp/3.14.9', 'x-client-id': 'c713ea8e50f657534ff8b9d373dfebfc2ed70b88285c26b8ade49868c0b164d9', }; const response = await this.axiosInstance.get(settings_1.LG_API_CONSTANTS.GATEWAY_URL, { headers: headers, }); if (response.data?.result) { this.gatewayInfo = { countryCode: this.country, languageCode: this.language, thinq2Uri: response.data.result.thinq2Uri, empUri: response.data.result.empTermsUri, empSpxUri: response.data.result.empSpxUri, thinq1Uri: response.data.result.thinq1Uri, }; return this.gatewayInfo; } else { const errorMsg = response.data?.lgedmRoot?.returnMsg || 'Unknown error'; const errorCode = response.data?.resultCode || 'Unknown'; throw new Error(`Gateway request failed: ${errorMsg} (Code: ${errorCode})`); } } catch (error) { throw new Error(`Failed to get gateway info: ${error}`); } } async getUserNumber() { try { const timestamp = luxon_1.DateTime.utc().toRFC2822(); const signature = this.createSignature(`/users/profile\n${timestamp}`, settings_1.LG_API_CONSTANTS.OAUTH_SECRET_KEY); const headers = { 'Accept': 'application/json', 'Authorization': 'Bearer ' + this.authData.accessToken, 'X-Lge-Svccode': 'SVC202', 'X-Application-Key': settings_1.LG_API_CONSTANTS.APPLICATION_KEY, 'lgemp-x-app-key': settings_1.LG_API_CONSTANTS.CLIENT_ID, 'X-Device-Type': 'M01', 'X-Device-Platform': 'ADR', 'x-lge-oauth-date': timestamp, 'x-lge-oauth-signature': signature, }; const response = await this.axiosInstance.get(`${this.authData.lgeapiUrl}users/profile`, { headers: headers, }); if (response.data?.status === 2) { throw new Error(response.data.message || 'Failed to get user profile'); } return response.data.account.userNo; } catch (error) { throw new Error(`Failed to get user number: ${error}`); } } async authenticateWithToken(refreshToken) { try { if (!this.gatewayInfo) { await this.getGatewayInfo(); } const tokenData = { grant_type: 'refresh_token', refresh_token: refreshToken, }; const timestamp = luxon_1.DateTime.utc().toRFC2822(); const requestUrl = '/oauth/1.0/oauth2/token?' + qs.stringify(tokenData); const headers = { 'x-lge-app-os': 'ADR', 'x-lge-appkey': settings_1.LG_API_CONSTANTS.CLIENT_ID, 'x-lge-oauth-signature': this.createSignature(`${requestUrl}\n${timestamp}`, settings_1.LG_API_CONSTANTS.OAUTH_SECRET_KEY), 'x-lge-oauth-date': timestamp, 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', }; const lgeapiUrl = `https://${this.country.toLowerCase()}.lgeapi.com/`; const response = await this.axiosInstance.post(`${lgeapiUrl}oauth/1.0/oauth2/token`, qs.stringify(tokenData), { headers }); if (response.data?.access_token) { this.authData = { accessToken: response.data.access_token, refreshToken: response.data.refresh_token || refreshToken, userId: '', countryCode: this.country, languageCode: this.language, lgeapiUrl: response.data.oauth2_backend_url ? decodeURIComponent(response.data.oauth2_backend_url) : lgeapiUrl, }; try { this.authData.userId = await this.getUserNumber(); } catch (error) { } this.tokenObtainedAt = new Date(); return this.authData; } else { throw new Error('Authentication failed: Invalid response'); } } catch (error) { if (error.response?.status === 415) { throw new Error('Token authentication failed: Unsupported Media Type - check request format'); } else if (error.response?.status === 401) { throw new Error('Token authentication failed: Invalid or expired refresh token'); } else { throw new Error(`Token authentication failed: ${error}`); } } } async authenticateWithCredentials(username, password) { try { if (!this.gatewayInfo) { await this.getGatewayInfo(); } if (!this.gatewayInfo || !this.gatewayInfo.empSpxUri || !this.gatewayInfo.empUri) { throw new Error('Gateway information incomplete - missing required URIs'); } const hashedPassword = crypto.createHash('sha512').update(password).digest('hex'); const defaultEmpHeaders = { 'Accept': 'application/json', 'X-Application-Key': settings_1.LG_API_CONSTANTS.APPLICATION_KEY, 'X-Client-App-Key': settings_1.LG_API_CONSTANTS.CLIENT_ID, 'X-Lge-Svccode': 'SVC709', 'X-Device-Type': 'M01', 'X-Device-Platform': 'ADR', 'X-Device-Language-Type': 'IETF', 'X-Device-Publish-Flag': 'Y', 'X-Device-Country': this.country, 'X-Device-Language': this.language, 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', 'Access-Control-Allow-Origin': '*', 'Accept-Encoding': 'gzip, deflate, br', 'Accept-Language': 'en-US,en;q=0.9', }; const preLoginData = { 'user_auth2': hashedPassword, 'log_param': 'login request / user_id : ' + username + ' / third_party : null / svc_list : SVC202,SVC710 / 3rd_service : ', }; const preLoginResponse = await this.axiosInstance.post(this.gatewayInfo.empSpxUri + '/preLogin', qs.stringify(preLoginData), { headers: defaultEmpHeaders }); if (!preLoginResponse.data?.signature || !preLoginResponse.data?.tStamp) { throw new Error('PreLogin failed - missing signature or timestamp'); } const loginHeaders = { ...defaultEmpHeaders, 'X-Signature': preLoginResponse.data.signature, 'X-Timestamp': preLoginResponse.data.tStamp, }; const loginData = { 'user_auth2': preLoginResponse.data.encrypted_pw, 'password_hash_prameter_flag': 'Y', 'svc_list': 'SVC202,SVC710', }; const loginUrl = `${this.gatewayInfo.empUri}/emp/v2.0/account/session/${encodeURIComponent(username)}`; const loginResponse = await this.axiosInstance.post(loginUrl, qs.stringify(loginData), { headers: loginHeaders }); if (!loginResponse.data?.account) { throw new Error('Invalid credentials or account not found'); } const account = loginResponse.data.account; if (!this.gatewayInfo.empSpxUri) { throw new Error('EMP SPX URI not available'); } const empSearchKeyUrl = this.gatewayInfo.empSpxUri + '/searchKey?key_name=OAUTH_SECRETKEY&sever_type=OP'; const secretKeyResponse = await this.axiosInstance.get(empSearchKeyUrl); const secretKey = secretKeyResponse.data.returnData; const empOAuthData = { account_type: account.userIDType, client_id: settings_1.LG_API_CONSTANTS.CLIENT_ID, country_code: account.country, redirect_uri: 'lgaccount.lgsmartthinq:/', response_type: 'code', state: '12345', username: account.userID, }; const empOAuthUrl = new URL('https://emp-oauth.lgecloud.com/emp/oauth2/authorize/empsession?' + qs.stringify(empOAuthData)); const timestamp = luxon_1.DateTime.utc().toRFC2822(); const signature = this.createSignature(`${empOAuthUrl.pathname}${empOAuthUrl.search}\n${timestamp}`, secretKey); const empOAuthHeaders = { 'lgemp-x-app-key': settings_1.LG_API_CONSTANTS.OAUTH_CLIENT_KEY, 'lgemp-x-date': timestamp, 'lgemp-x-session-key': account.loginSessionID, 'lgemp-x-signature': signature, 'Accept': 'application/json', 'X-Device-Type': 'M01', 'X-Device-Platform': 'ADR', 'Content-Type': 'application/x-www-form-urlencoded', 'Access-Control-Allow-Origin': '*', 'Accept-Encoding': 'gzip, deflate, br', 'Accept-Language': 'en-US,en;q=0.9', 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36 Edg/93.0.961.44', }; const authorizeResponse = await this.axiosInstance.get(empOAuthUrl.href, { headers: empOAuthHeaders, }); if (authorizeResponse.data.status !== 1) { throw new Error('Authorization failed: ' + (authorizeResponse.data.message || 'Unknown error')); } const redirectUri = new URL(authorizeResponse.data.redirect_uri); const authCode = redirectUri.searchParams.get('code'); const oauthBackendUrl = redirectUri.searchParams.get('oauth2_backend_url'); if (!authCode || !oauthBackendUrl) { throw new Error('Failed to extract authorization code or backend URL'); } const tokenData = { code: authCode, grant_type: 'authorization_code', redirect_uri: empOAuthData.redirect_uri, }; const requestUrl = '/oauth/1.0/oauth2/token?' + qs.stringify(tokenData); const tokenResponse = await this.axiosInstance.post(oauthBackendUrl + 'oauth/1.0/oauth2/token', qs.stringify(tokenData), { headers: { 'x-lge-app-os': 'ADR', 'x-lge-appkey': settings_1.LG_API_CONSTANTS.CLIENT_ID, 'x-lge-oauth-signature': this.createSignature(`${requestUrl}\n${timestamp}`, settings_1.LG_API_CONSTANTS.OAUTH_SECRET_KEY), 'x-lge-oauth-date': timestamp, 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', }, }); if (!tokenResponse.data?.access_token) { throw new Error('Failed to obtain access token'); } this.authData = { accessToken: tokenResponse.data.access_token, refreshToken: tokenResponse.data.refresh_token, userId: '', countryCode: this.country, languageCode: this.language, lgeapiUrl: decodeURIComponent(tokenResponse.data.oauth2_backend_url), }; this.authData.userId = await this.getUserNumber(); this.tokenObtainedAt = new Date(); return this.authData; } catch (error) { throw new Error(`Credential authentication failed: ${error}`); } } async getDevices() { try { if (!this.authData || !this.gatewayInfo) { throw new Error('Not authenticated'); } const homesResponse = await this.axiosInstance.get(`${this.gatewayInfo.thinq2Uri}/service/homes`, { headers: { 'x-api-key': 'VGhpblEyLjAgU0VSVklDRQ==', '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.authData.countryCode, 'x-language-code': this.authData.languageCode, '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': this.generateRandomString(22), 'user-agent': 'okhttp/3.14.9', 'x-emp-token': this.authData.accessToken, 'x-user-no': this.authData.userId, 'x-client-id': settings_1.LG_API_CONSTANTS.CLIENT_ID, }, }); const homes = homesResponse.data?.result?.item || []; const allDevices = []; for (const home of homes) { const devicesResponse = await this.axiosInstance.get(`${this.gatewayInfo.thinq2Uri}/service/homes/${home.homeId}`, { headers: { 'x-api-key': 'VGhpblEyLjAgU0VSVklDRQ==', '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.authData.countryCode, 'x-language-code': this.authData.languageCode, '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': this.generateRandomString(22), 'user-agent': 'okhttp/3.14.9', 'x-emp-token': this.authData.accessToken, 'x-user-no': this.authData.userId, 'x-client-id': settings_1.LG_API_CONSTANTS.CLIENT_ID, }, }); const homeDevices = devicesResponse.data?.result?.devices || []; allDevices.push(...homeDevices); } return allDevices; } catch (error) { throw new Error(`Failed to get devices: ${error}`); } } async getDeviceStatus(deviceId) { try { if (!this.authData || !this.gatewayInfo) { throw new Error('Not authenticated'); } console.log(`[LG API DEBUG] Getting device status for device: ${deviceId}`); console.log(`[LG API DEBUG] Using auth token: ${this.authData.accessToken ? this.authData.accessToken.substring(0, 20) + '...' : 'null'}`); console.log(`[LG API DEBUG] Request URL: ${this.gatewayInfo.thinq2Uri}/service/devices/${deviceId}`); const response = await this.axiosInstance.get(`${this.gatewayInfo.thinq2Uri}/service/devices/${deviceId}`, { headers: { 'x-api-key': 'VGhpblEyLjAgU0VSVklDRQ==', '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.authData.countryCode, 'x-language-code': this.authData.languageCode, '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': this.generateRandomString(22), 'user-agent': 'okhttp/3.14.9', 'x-emp-token': this.authData.accessToken, 'x-user-no': this.authData.userId, 'x-client-id': settings_1.LG_API_CONSTANTS.CLIENT_ID, }, }); console.log(`[LG API DEBUG] Device status response status: ${response.status}`); console.log('[LG API DEBUG] Device status response data:', JSON.stringify(response.data, null, 2)); return response.data?.result || {}; } catch (error) { console.error(`[LG API ERROR] Device status request failed for device ${deviceId}:`); console.error(`[LG API ERROR] Status: ${error.response?.status || 'unknown'}`); console.error(`[LG API ERROR] Status Text: ${error.response?.statusText || 'unknown'}`); console.error('[LG API ERROR] Response Headers:', error.response?.headers || 'none'); console.error('[LG API ERROR] Response Data:', JSON.stringify(error.response?.data, null, 2) || 'none'); console.error(`[LG API ERROR] Request URL: ${this.gatewayInfo?.thinq2Uri}/service/devices/${deviceId}`); console.error(`[LG API ERROR] Auth token exists: ${!!this.authData?.accessToken}`); console.error(`[LG API ERROR] User ID exists: ${!!this.authData?.userId}`); if (error.response?.status === 400) { throw new Error(`Bad Request (400): ${JSON.stringify(error.response.data) || 'Invalid request format or parameters'}`); } else if (error.response?.status === 401) { throw new Error(`Unauthorized (401): ${JSON.stringify(error.response.data) || 'Authentication token expired or invalid'}`); } else if (error.response?.status === 403) { throw new Error(`Forbidden (403): ${JSON.stringify(error.response.data) || 'Access denied to device'}`); } else if (error.response?.status === 404) { throw new Error(`Not Found (404): Device ${deviceId} not found`); } else if (error.response?.status === 429) { throw new Error('Rate Limited (429): Too many requests, please wait before retrying'); } else if (error.response?.status >= 500) { throw new Error(`Server Error (${error.response.status}): LG API server issue - ${JSON.stringify(error.response.data) || 'Internal server error'}`); } throw new Error(`Failed to get device status: ${error.message || error}`); } } async sendCommand(deviceId, command) { try { if (!this.authData || !this.gatewayInfo) { throw new Error('Not authenticated'); } const requestData = { ctrlKey: 'basicCtrl', command: 'Set', dataKey: command.dataKey, dataValue: command.dataValue, }; console.log(`[LG API DEBUG] Sending command to device: ${deviceId}`); console.log('[LG API DEBUG] Command data:', JSON.stringify(requestData, null, 2)); console.log(`[LG API DEBUG] Using auth token: ${this.authData.accessToken ? this.authData.accessToken.substring(0, 20) + '...' : 'null'}`); console.log(`[LG API DEBUG] Request URL: ${this.gatewayInfo.thinq2Uri}/service/devices/${deviceId}/control-sync`); const response = await this.axiosInstance.post(`${this.gatewayInfo.thinq2Uri}/service/devices/${deviceId}/control-sync`, requestData, { headers: { 'x-api-key': 'VGhpblEyLjAgU0VSVklDRQ==', '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.authData.countryCode, 'x-language-code': this.authData.languageCode, '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': this.generateRandomString(22), 'user-agent': 'okhttp/3.14.9', 'x-emp-token': this.authData.accessToken, 'x-user-no': this.authData.userId, 'x-client-id': settings_1.LG_API_CONSTANTS.CLIENT_ID, 'Content-Type': 'application/json', }, }); console.log(`[LG API DEBUG] Command response status: ${response.status}`); console.log('[LG API DEBUG] Command response data:', JSON.stringify(response.data, null, 2)); return response.data?.result || {}; } catch (error) { console.error(`[LG API ERROR] Command request failed for device ${deviceId}:`); console.error('[LG API ERROR] Command:', JSON.stringify(command, null, 2)); console.error(`[LG API ERROR] Status: ${error.response?.status || 'unknown'}`); console.error(`[LG API ERROR] Status Text: ${error.response?.statusText || 'unknown'}`); console.error('[LG API ERROR] Response Headers:', error.response?.headers || 'none'); console.error('[LG API ERROR] Response Data:', JSON.stringify(error.response?.data, null, 2) || 'none'); console.error(`[LG API ERROR] Request URL: ${this.gatewayInfo?.thinq2Uri}/service/devices/${deviceId}/control-sync`); console.error(`[LG API ERROR] Auth token exists: ${!!this.authData?.accessToken}`); console.error(`[LG API ERROR] User ID exists: ${!!this.authData?.userId}`); if (error.response?.status === 400) { throw new Error(`Bad Request (400): ${JSON.stringify(error.response.data) || 'Invalid command format or parameters'}`); } else if (error.response?.status === 401) { throw new Error(`Unauthorized (401): ${JSON.stringify(error.response.data) || 'Authentication token expired or invalid'}`); } else if (error.response?.status === 403) { throw new Error(`Forbidden (403): ${JSON.stringify(error.response.data) || 'Access denied to device'}`); } else if (error.response?.status === 404) { throw new Error(`Not Found (404): Device ${deviceId} not found or endpoint not available`); } else if (error.response?.status === 429) { throw new Error('Rate Limited (429): Too many requests, please wait before retrying'); } else if (error.response?.status >= 500) { throw new Error(`Server Error (${error.response.status}): LG API server issue - ${JSON.stringify(error.response.data) || 'Internal server error'}`); } throw new Error(`Failed to send command: ${error.message || error}`); } } async refreshAccessToken() { if (!this.authData) { throw new Error('No authentication data available'); } try { console.log('[LG API DEBUG] Refreshing access token'); const updatedAuth = await this.authenticateWithToken(this.authData.refreshToken); this.authData = updatedAuth; this.tokenObtainedAt = new Date(); console.log('[LG API DEBUG] Access token refreshed successfully'); } catch (error) { console.error('[LG API ERROR] Failed to refresh access token:', error.message); throw new Error(`Failed to refresh access token: ${error.message}`); } } async autoRefreshWithCredentials(username, password) { try { console.log('[LG API DEBUG] Starting auto-refresh process'); if (this.authData?.refreshToken) { try { console.log('[LG API DEBUG] Attempting refresh token renewal'); await this.refreshAccessToken(); console.log('[LG API DEBUG] Refresh token renewal successful'); return this.authData; } catch (error) { console.warn(`[LG API WARN] Refresh token renewal failed: ${error.message}, falling back to credentials`); } } else { console.log('[LG API DEBUG] No refresh token available, using credentials directly'); } console.log('[LG API DEBUG] Re-authenticating with username/password'); const newAuth = await this.authenticateWithCredentials(username, password); this.authData = newAuth; console.log('[LG API DEBUG] Re-authentication successful'); console.log(`[LG API DEBUG] New token expires: ${newAuth.accessToken ? 'exists' : 'missing'}`); return newAuth; } catch (error) { console.error('[LG API ERROR] Auto-refresh completely failed:', error.message); throw new Error(`Auto-refresh failed: ${error.message}`); } } isTokenExpired() { if (!this.tokenObtainedAt) { return true; } const tokenExpiresInMinutes = 30; const currentTime = new Date(); const tokenObtainedTime = new Date(this.tokenObtainedAt); const tokenAge = Math.floor((currentTime.getTime() - tokenObtainedTime.getTime()) / 60000); return tokenAge > tokenExpiresInMinutes; } isCircuitBreakerOpen() { if (!this.circuitBreakerOpen) { return false; } if (this.lastApiError && (new Date().getTime() - this.lastApiError.getTime()) > 5 * 60 * 1000) { console.log('[LG API DEBUG] Circuit breaker reset after 5 minutes'); this.circuitBreakerOpen = false; this.consecutiveErrors = 0; return false; } return true; } recordApiSuccess() { this.consecutiveErrors = 0; this.circuitBreakerOpen = false; this.lastApiError = null; } recordApiError() { this.lastApiError = new Date(); this.consecutiveErrors++; if (this.consecutiveErrors >= 5) { console.log(`[LG API WARN] Circuit breaker opened due to ${this.consecutiveErrors} consecutive errors`); this.circuitBreakerOpen = true; } } async executeWithRetry(apiCall, username, password) { if (this.isCircuitBreakerOpen()) { throw new Error('Circuit breaker is open due to consecutive API failures. Service will retry automatically in a few minutes.'); } try { console.log('[LG API DEBUG] Executing API call with retry capability'); console.log(`[LG API DEBUG] Auth token available: ${!!this.authData?.accessToken}`); console.log(`[LG API DEBUG] Refresh token available: ${!!this.authData?.refreshToken}`); console.log(`[LG API DEBUG] Credentials available for fallback: ${!!(username && password)}`); console.log(`[LG API DEBUG] Consecutive errors: ${this.consecutiveErrors}`); const result = await apiCall(); this.recordApiSuccess(); return result; } catch (error) { this.recordApiError(); const status = error.response?.status; console.error(`[LG API ERROR] API call failed with status: ${status}`); console.error('[LG API ERROR] Error details:', JSON.stringify(error.response?.data, null, 2)); console.error(`[LG API ERROR] Consecutive errors: ${this.consecutiveErrors}`); if (status === 401 || status === 403 || status === 400) { console.log(`[LG API DEBUG] Authentication-related error detected (${status}), attempting token refresh`); if (username && password) { try { console.log('[LG API DEBUG] Attempting auto-refresh with credentials'); await this.autoRefreshWithCredentials(username, password); console.log('[LG API DEBUG] Auto-refresh successful, retrying original API call'); const result = await apiCall(); this.recordApiSuccess(); return result; } catch (refreshError) { console.error('[LG API ERROR] Auto-refresh failed:', refreshError.message); throw new Error(`API call failed and auto-refresh failed: ${refreshError.message}`); } } else { console.error('[LG API ERROR] No credentials available for auto-refresh'); if (status === 400) { throw new Error('Bad Request (400): This may indicate expired authentication or invalid request format. ' + `Consider restarting Homebridge to re-authenticate. Original error: ${error.message}`); } throw new Error(`Authentication error (${status}) and no credentials available for auto-refresh. ` + `Original error: ${error.message}`); } } throw error; } } isAuthenticated() { const hasAuth = this.authData !== null && this.authData.accessToken !== ''; const tokenValid = !this.isTokenExpired(); console.log(`[LG API DEBUG] Authentication check: hasAuth=${hasAuth}, tokenValid=${tokenValid}`); if (this.tokenObtainedAt) { const tokenAge = Math.floor((new Date().getTime() - this.tokenObtainedAt.getTime()) / 60000); console.log(`[LG API DEBUG] Token age: ${tokenAge} minutes`); } return hasAuth && tokenValid; } async validateAuthentication() { if (!this.authData || !this.authData.accessToken) { console.log('[LG API DEBUG] No authentication data available'); return false; } if (this.isTokenExpired()) { console.log('[LG API DEBUG] Token appears to be expired, validation suggests refresh needed'); return false; } console.log('[LG API DEBUG] Authentication appears valid'); return true; } getAuthData() { return this.authData; } } exports.LGApi = LGApi;