UNPKG

homebridge-melcloud-control

Version:

Homebridge plugin to control Mitsubishi Air Conditioner, Heat Pump and Energy Recovery Ventilation.

847 lines (696 loc) 38.1 kB
import axios from 'axios'; import http from 'http'; import https from 'https'; import WebSocket from 'ws'; import crypto from 'crypto'; import EventEmitter from 'events'; import ImpulseGenerator from './impulsegenerator.js'; import Functions from './functions.js'; import RequestPacer from './requestpacer.js'; import { CookieJar } from 'tough-cookie'; import { wrapper } from 'axios-cookiejar-support'; import { URL } from 'url'; import { ApiUrls } from './constants.js'; class MelCloudHome extends EventEmitter { constructor(account, pluginStart = false) { super(); this.user = account.user; this.passwd = account.passwd; this.logSuccess = account.log?.success; this.logInfo = account.log?.info; this.logWarn = account.log?.warn; this.logError = account.log?.error; this.logDebug = account.log?.debug; this.pluginStart = pluginStart; this.functions = new Functions(this.logWarn, this.logError, this.logDebug) .on('warn', warn => this.emit('warn', warn)) .on('error', error => this.emit('error', error)) .on('debug', debug => this.emit('debug', debug)); this.pacer = new RequestPacer(); // Axios clients this.authClient = null; // cookie-jar client used only during the auth flow this.client = null; // API client used for all post-login requests // Token state this.accessToken = null; this.refreshToken = null; this.tokenExpiry = 0; // Unix timestamp (seconds) // Flag preventing duplicate interceptor registration on re-login this.interceptorsAttached = false; // WebSocket state this.socket = null; this.socketConnected = false; this.connecting = false; this.heartbeat = null; this.reconnectTimer = null; this.reconnectDelay = 5_000; // ms, grows exponentially up to reconnectDelayMax this.reconnectDelayMax = 300_000; // 5 minutes if (pluginStart) { this.impulseGenerator = new ImpulseGenerator() .on('checkDevicesList', async () => { try { await this.checkDevicesListWithRetry(); } catch (error) { if (this.logError) this.emit('error', `checkDevicesList error: ${error.message}`); } }) .on('state', (state) => { this.emit(state ? 'success' : 'warn', `Impulse generator ${state ? 'started' : 'stopped'}`); }); } } // ── WebSocket ───────────────────────────────────────────────────────────── // Resets all WebSocket state and clears the heartbeat interval. cleanupSocket() { if (this.heartbeat) { clearInterval(this.heartbeat); this.heartbeat = null; } this.socket = null; this.socketConnected = false; this.connecting = false; } // Opens a WebSocket connection using the user ID from /api/user/context as the hash. // Called automatically after a successful login and on every reconnect attempt. async connectSocket() { if (this.connecting || this.socketConnected) return; this.connecting = true; let hash; try { const resp = await this.client.get(ApiUrls.Home.Get.Context); hash = resp.data?.id ?? null; if (!hash) throw new Error('id field missing in context response'); } catch (err) { if (this.logError) this.emit('error', `WebSocket: cannot get hash: ${err.message}`); this.connecting = false; this.scheduleReconnect(); return; } const url = `${ApiUrls.Home.WebSocket}${hash}`; const headers = { Origin: ApiUrls.Home.Base, Pragma: 'no-cache', 'Cache-Control': 'no-cache', }; if (this.logDebug) this.emit('debug', `WebSocket connecting: ${url.slice(0, 60)}...`); try { const ws = new WebSocket(url, { headers }); this.socket = ws; ws.on('error', (error) => { if (this.logError) this.emit('error', `WebSocket error: ${error.message}`); try { ws.close(); } catch { /* ignore if already closed */ } }); ws.on('close', () => { if (this.logDebug) this.emit('debug', 'WebSocket closed'); this.cleanupSocket(); this.scheduleReconnect(); }); ws.on('open', () => { this.socketConnected = true; this.connecting = false; this.reconnectDelay = 5_000; // reset backoff on successful connection if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } if (this.logSuccess && this.pluginStart) this.emit('success', 'WebSocket connected'); this.pluginStart = false; // only log the first successful connection after plugin start // Send a ping every 30 s to keep the connection alive this.heartbeat = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { if (this.logDebug) this.emit('debug', 'WebSocket heartbeat sent'); ws.ping(); } }, 30_000); }); ws.on('pong', () => { if (this.logDebug) this.emit('debug', 'WebSocket heartbeat received'); }); ws.on('message', (message) => { try { const parsed = JSON.parse(message); const messageData = parsed?.[0]?.Data; if (this.logDebug) this.emit('debug', `WebSocket message: ${JSON.stringify(parsed, null, 2)}`); // Ignore empty payloads and server-side auth errors if (!messageData || parsed.message === 'Forbidden') return; this.emit(messageData.id, 'ws', parsed[0]); } catch (err) { if (this.logError) this.emit('error', `WebSocket message parse error: ${err.message}`); } }); } catch (error) { if (this.logError) this.emit('error', `WebSocket connection failed: ${error.message}`); this.cleanupSocket(); this.scheduleReconnect(); } } // Schedules a reconnect attempt using exponential backoff (5 s → 10 s → … → 5 min). scheduleReconnect() { if (this.reconnectTimer) return; // already scheduled if (this.logDebug) this.emit('debug', `WebSocket reconnecting in ${this.reconnectDelay / 1000} s...`); this.reconnectTimer = setTimeout(async () => { this.reconnectTimer = null; await this.connectSocket(); }, this.reconnectDelay); this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.reconnectDelayMax); } // ── Utils ───────────────────────────────────────────────────────────────── // Recursively capitalizes the first letter of every object key. capitalizeKeysDeep(obj) { if (Array.isArray(obj)) return obj.map(item => this.capitalizeKeysDeep(item)); if (obj && typeof obj === 'object') { return Object.fromEntries( Object.entries(obj).map(([k, v]) => [ k.charAt(0).toUpperCase() + k.slice(1), this.capitalizeKeysDeep(v), ]) ); } return obj; } // ── Token state ─────────────────────────────────────────────────────────── // Returns true when the access token is absent or expires within 60 seconds. isTokenExpired() { if (!this.accessToken) return true; return Date.now() / 1000 >= this.tokenExpiry - 60; } // ── Axios clients ───────────────────────────────────────────────────────── // Returns (creating if needed) the cookie-jar client used during the OAuth flow. ensureAuthClient() { if (this.authClient) return this.authClient; const jar = new CookieJar(); this.authClient = wrapper( axios.create({ jar, timeout: 30_000, headers: { Accept: 'application/json', 'User-Agent': ApiUrls.Home.UserAgent, }, maxRedirects: 5, validateStatus: () => true, // handle all status codes manually }) ); return this.authClient; } // Returns (creating if needed) the API client used for all post-login requests. // Uses a keepAlive agent with a short socket timeout to prevent stale connections // from causing indefinite hangs after server-side idle timeouts (~5 h symptom). ensureClient() { if (this.client) return this.client; // keepAlive reuses TCP connections; freeSocketTimeout closes idle sockets // before the server silently drops them (typically after a few minutes). const agentOptions = { keepAlive: true, freeSocketTimeout: 30_000 }; this.client = axios.create({ baseURL: ApiUrls.Home.Base, timeout: 30_000, headers: { Accept: 'application/json', 'User-Agent': ApiUrls.Home.UserAgent, }, httpAgent: new http.Agent(agentOptions), httpsAgent: new https.Agent(agentOptions), }); return this.client; } // ── Pacer ───────────────────────────────────────────────────────────────── pace(fn) { return this.pacer.run(fn); } // ── PKCE ────────────────────────────────────────────────────────────────── generatePkce() { const verifier = crypto.randomBytes(32).toString('base64url'); const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); return { verifier, challenge }; } // ── CSRF token ──────────────────────────────────────────────────────────── // Extracts the _csrf token value from the Cognito login page HTML. extractCsrfToken(html) { return ( /<input[^>]+name="_csrf"[^>]+value="([^"]+)"/.exec(html)?.[1] ?? /<input[^>]+value="([^"]+)"[^>]+name="_csrf"/.exec(html)?.[1] ?? /name="_csrf"\s+value="([^"]+)"/.exec(html)?.[1] ?? null ); } // ── OAuth helpers ───────────────────────────────────────────────────────── // Follows the /connect/authorize/callback redirect chain and returns the auth code. async followCallbackForCode(client, callbackQs) { const qs = callbackQs.replace(/&amp;/g, '&'); const callbackUrl = `${ApiUrls.Home.AuthBase}/connect/authorize/callback?${qs}`; const resp = await this.pace(() => client.get(callbackUrl, { headers: { 'User-Agent': ApiUrls.Home.UserAgent }, maxRedirects: 0, }) ); let location = resp.headers?.location ?? ''; if (location.startsWith('melcloudhome://')) { const m = /code=([^&]+)/.exec(location); if (m) return m[1]; } if (!location || location === '/') throw new Error('Callback returned empty or root redirect'); // One additional hop if needed const redirectUrl = location.startsWith('http') ? location : `${ApiUrls.Home.AuthBase}${location}`; const resp2 = await this.pace(() => client.get(redirectUrl, { headers: { 'User-Agent': ApiUrls.Home.UserAgent }, maxRedirects: 0, }) ); location = resp2.headers?.location ?? ''; const m = /code=([^&]+)/.exec(location); if (!m) throw new Error('Failed to extract auth code from redirect'); return m[1]; } // Exchanges an authorization code for access and refresh tokens. async exchangeCodeForTokens(client, authCode, codeVerifier) { if (this.logDebug) this.emit('debug', 'Step 6: Token exchange'); const resp = await this.pace(() => client.post( `${ApiUrls.Home.AuthBase}/connect/token`, new URLSearchParams({ grant_type: 'authorization_code', code: authCode, redirect_uri: ApiUrls.Home.OauthRedirectUri, code_verifier: codeVerifier, client_id: ApiUrls.Home.OauthClientId, }), { headers: { 'User-Agent': ApiUrls.Home.UserAgent } } ) ); if (resp.status >= 500) throw new Error(`Token exchange server error: HTTP ${resp.status}`); if (resp.status !== 200) throw new Error(`Token exchange failed: HTTP ${resp.status}`); this.accessToken = resp.data.access_token; this.refreshToken = resp.data.refresh_token ?? this.refreshToken; this.tokenExpiry = Date.now() / 1000 + (resp.data.expires_in ?? 3600); if (this.logDebug) this.emit('debug', 'Authentication successful'); return true; } // ── Token refresh ───────────────────────────────────────────────────────── // Uses the stored refresh token to obtain a new access token. async refreshAccessToken() { if (!this.refreshToken) throw new Error('No refresh token available'); const client = this.ensureAuthClient(); const resp = await this.pace(() => client.post( `${ApiUrls.Home.AuthBase}/connect/token`, new URLSearchParams({ grant_type: 'refresh_token', refresh_token: this.refreshToken, client_id: ApiUrls.Home.OauthClientId, }), { headers: { 'User-Agent': ApiUrls.Home.UserAgent } } ) ); if (resp.status !== 200) { this.accessToken = null; this.refreshToken = null; throw new Error('Refresh token rejected'); } this.accessToken = resp.data.access_token; this.refreshToken = resp.data.refresh_token ?? this.refreshToken; this.tokenExpiry = Date.now() / 1000 + (resp.data.expires_in ?? 3600); return true; } // Attempts a token refresh; falls back to a full re-login if the refresh token is // missing or rejected. A single shared Promise prevents concurrent refresh races. async refreshOrRelogin() { if (this.refreshPromise) return this.refreshPromise; this.refreshPromise = (async () => { if (this.refreshToken) { try { await this.refreshAccessToken(); if (this.logDebug) this.emit('debug', 'Token refreshed successfully'); return; } catch (err) { if (this.logDebug) this.emit('debug', `Refresh token rejected (${err.message}), falling back to full re-login`); } } if (this.logDebug) this.emit('debug', 'Performing full re-login'); await this.connect(); })().finally(() => { this.refreshPromise = null; }); return this.refreshPromise; } // ── Token interceptors ──────────────────────────────────────────────────── // Attaches request and response interceptors to the API client. // Safe to call multiple times — interceptors are registered only once. attachTokenInterceptors() { if (this.interceptorsAttached) return; this.interceptorsAttached = true; const apiClient = this.ensureClient(); // Inject a fresh Authorization header before every request. // If the token is expired, refresh it first. apiClient.interceptors.request.use(async (config) => { if (this.isTokenExpired()) { if (this.logDebug) this.emit('debug', 'Token expired — refreshing before request'); await this.refreshOrRelogin(); } config.headers['Authorization'] = `Bearer ${this.accessToken}`; return config; }); // On 401, refresh the token and retry the original request exactly once. apiClient.interceptors.response.use( response => response, async (error) => { const original = error.config; if (error.response?.status === 401 && !original.retried) { original.retried = true; if (this.logDebug) this.emit('debug', 'Got 401 — refreshing token and retrying'); try { await this.refreshOrRelogin(); original.headers['Authorization'] = `Bearer ${this.accessToken}`; return apiClient(original); } catch (refreshError) { this.emit('error', `Token refresh failed: ${refreshError.message}`); return Promise.reject(refreshError); } } return Promise.reject(error); } ); } // ── Post-login setup ────────────────────────────────────────────────────── // Finalises the connect flow: sets up the API client, attaches interceptors, // emits the 'client' event and opens the WebSocket connection. async buildConnectInfo(connectInfo, exchangeRes) { if (exchangeRes) { this.ensureClient(); this.attachTokenInterceptors(); if (this.pluginStart) { this.emit('client', this.client); await this.connectSocket().catch(err => { if (this.logError) this.emit('error', `WebSocket initial connect failed: ${err.message}`); }); } } connectInfo.State = exchangeRes; connectInfo.Status = exchangeRes ? 'Connect Success' : 'Connect Failed'; return connectInfo; } // ── Connect ─────────────────────────────────────────────────────────────── // Full OAuth 2.0 PKCE login flow: // Step 1 — Pushed Authorization Request (PAR) // Step 2 — Authorize redirect → Cognito login page (or fast-path if session exists) // Step 3 — POST credentials to Cognito (maxRedirects: 0 to intercept form_post) // Step 4 — POST Cognito callback params to IdentityServer /signin-oidc-meu // Step 5 — Follow redirect chain until the auth code is found // Step 6 — Exchange auth code for access + refresh tokens async connect() { if (this.logDebug) this.emit('debug', 'Connecting to MELCloud Home'); try { const connectInfo = { State: false, Status: '', Account: {}, UseFahrenheit: false }; const client = this.ensureAuthClient(); const { verifier: codeVerifier, challenge: codeChallenge } = this.generatePkce(); const state = crypto.randomBytes(16).toString('base64url'); // ── Step 1: PAR ─────────────────────────────────────────────────── if (this.logDebug) this.emit('debug', 'Step 1: PAR request'); const parResp = await this.pace(() => client.post( `${ApiUrls.Home.AuthBase}/connect/par`, new URLSearchParams({ response_type: 'code', state, code_challenge: codeChallenge, code_challenge_method: 'S256', client_id: ApiUrls.Home.OauthClientId, scope: ApiUrls.Home.OauthScopes, redirect_uri: ApiUrls.Home.OauthRedirectUri, }), { headers: { 'User-Agent': ApiUrls.Home.UserAgent } } ) ); if (parResp.status >= 500) throw new Error(`PAR server error: HTTP ${parResp.status}`); if (parResp.status !== 201) throw new Error(`PAR request failed: HTTP ${parResp.status}`); const requestUri = parResp.data.request_uri; if (this.logDebug) this.emit('debug', `PAR OK: request_uri=${requestUri.slice(0, 50)}...`); // ── Step 2: Authorize → Cognito login page ──────────────────────── if (this.logDebug) this.emit('debug', 'Step 2: Authorize redirect to Cognito'); const authorizeUrl = `${ApiUrls.Home.AuthBase}/connect/authorize` + `?client_id=${ApiUrls.Home.OauthClientId}&request_uri=${requestUri}`; let authCode = null; let cognitoLoginUrl = null; let csrfToken = null; const authResp = await this.pace(() => client.get(authorizeUrl, { headers: { 'User-Agent': ApiUrls.Home.UserAgent }, maxRedirects: 5, }) ); if (authResp.status >= 500) throw new Error(`Authorize server error: HTTP ${authResp.status}`); const finalUrl = authResp.request?.res?.responseUrl ?? authorizeUrl; const parsed = new URL(finalUrl); const body = typeof authResp.data === 'string' ? authResp.data : JSON.stringify(authResp.data); if (parsed.hostname?.endsWith(ApiUrls.Home.CognitoDomainSuffix) && parsed.pathname.includes('/login')) { // Happy path: landed on the Cognito login page csrfToken = this.extractCsrfToken(body); if (!csrfToken) throw new Error('Failed to extract CSRF token from Cognito login page'); cognitoLoginUrl = finalUrl; if (this.logDebug) this.emit('debug', 'Cognito login page OK'); } else { // Fast path: existing IdentityServer session — auth code available immediately const codeMatch = /code=([^&"' ]+)/.exec(finalUrl) || /code=([^&"' ]+)/.exec(body); if (codeMatch) { authCode = codeMatch[1]; if (this.logDebug) this.emit('debug', 'Existing session detected, got auth code directly'); } else { const cbMatch = /\/connect\/authorize\/callback\?([^"' ]+)/.exec(body); if (cbMatch) { authCode = await this.followCallbackForCode(client, cbMatch[1]); if (this.logDebug) this.emit('debug', 'Existing session: followed callback for code'); } else { throw new Error(`Unexpected auth response: ${finalUrl}`); } } } // Skip credential submission when we already have a code if (authCode) { if (this.logDebug) this.emit('debug', 'Re-login with existing session (skipping credentials)'); const exchangeRes = await this.exchangeCodeForTokens(client, authCode, codeVerifier); return await this.buildConnectInfo(connectInfo, exchangeRes); } // ── Step 3: Submit credentials to Cognito ───────────────────────── // maxRedirects: 0 — Cognito uses response_mode=form_post, so after a // successful login it POSTs back to IdentityServer (/signin-oidc-meu). // We intercept the 302 before axios follows it to avoid a 500 from Kestrel. if (this.logDebug) this.emit('debug', 'Step 3: Submit credentials to Cognito'); const cognitoHostname = new URL(cognitoLoginUrl).hostname; const credResp = await this.pace(() => client.post( cognitoLoginUrl, new URLSearchParams({ _csrf: csrfToken, username: this.user, password: this.passwd, cognitoAsfData: '', }), { headers: { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/22F76', 'Content-Type': 'application/x-www-form-urlencoded', Origin: `https://${cognitoHostname}`, Referer: cognitoLoginUrl, }, maxRedirects: 0, } ) ); if (this.logDebug) { this.emit('debug', `Step 3 response status: ${credResp.status}`); this.emit('debug', `Step 3 response location: ${credResp.headers?.location ?? '(none)'}`); } // HTTP 200 means Cognito returned the login page again → wrong password if (credResp.status === 200) throw new Error('Authentication failed: Invalid username or password'); if (credResp.status >= 500) throw new Error(`Cognito server error: HTTP ${credResp.status}`); // ── Step 4: POST Cognito callback params to IdentityServer ───────── // Cognito normally POSTs code+state to /signin-oidc-meu (form_post). // We received a 302 with those params in the query string, so we replay // them as a POST body — exactly as Cognito would have done. if (this.logDebug) this.emit('debug', 'Step 4: Follow Cognito → IdentityServer redirect'); const cognitoRedirectLocation = credResp.headers?.location ?? ''; if (!cognitoRedirectLocation) throw new Error('No Location header in Cognito response'); if (this.logDebug) this.emit('debug', `Step 4 location: ${cognitoRedirectLocation}`); const signinParsed = new URL(cognitoRedirectLocation); const signinBase = `${signinParsed.protocol}//${signinParsed.host}${signinParsed.pathname}`; const signinParams = new URLSearchParams(signinParsed.search); if (this.logDebug) this.emit('debug', `Step 4 POST to: ${signinBase} params: ${[...signinParams.keys()].join(', ')}`); const signinResp = await this.pace(() => client.post(signinBase, signinParams, { headers: { 'User-Agent': ApiUrls.Home.UserAgent, 'Content-Type': 'application/x-www-form-urlencoded', }, maxRedirects: 0, }) ); if (this.logDebug) { this.emit('debug', `Step 4 signin status: ${signinResp.status}`); this.emit('debug', `Step 4 signin location: ${signinResp.headers?.location ?? '(none)'}`); } // ── Step 5: Follow redirect chain until the auth code is found ──── // IdentityServer redirects through several hops: // /ExternalLogin/Callback → /connect/authorize/callback → melcloudhome:// if (this.logDebug) this.emit('debug', 'Step 5: Following redirect chain to auth code'); let currentResp = signinResp; const MAX_HOPS = 6; for (let hop = 0; hop < MAX_HOPS; hop++) { const hopStatus = currentResp.status; const hopLocation = currentResp.headers?.location ?? ''; const hopBody = typeof currentResp.data === 'string' ? currentResp.data : ''; if (this.logDebug) this.emit('debug', `Step 5 hop ${hop}: status=${hopStatus} location=${hopLocation || '(none)'}`); // A: custom scheme redirect carrying the auth code if (hopLocation.startsWith('melcloudhome://')) { const m = /code=([^&"' ]+)/.exec(hopLocation); if (m) { authCode = m[1]; break; } } // B: IdentityServer authorize callback — delegate to helper const cbMatch = /\/connect\/authorize\/callback\?([^"' ]+)/.exec(hopLocation) || /\/connect\/authorize\/callback\?([^"' ]+)/.exec(hopBody); if (cbMatch) { if (this.logDebug) this.emit('debug', 'Step 5: delegating to followCallbackForCode'); authCode = await this.followCallbackForCode(client, cbMatch[1]); break; } // C: auth code directly in the Location header const codeInLocation = /code=([^&"' ]+)/.exec(hopLocation); if (codeInLocation) { authCode = codeInLocation[1]; break; } // D: auth code in the response body const codeInBody = /code=([^&"' ]+)/.exec(hopBody); if (codeInBody) { authCode = codeInBody[1]; break; } // Standard redirect — follow the next hop if ((hopStatus === 301 || hopStatus === 302 || hopStatus === 303) && hopLocation) { const nextUrl = hopLocation.startsWith('http') ? hopLocation : `${ApiUrls.Home.AuthBase}${hopLocation}`; currentResp = await this.pace(() => client.get(nextUrl, { headers: { 'User-Agent': ApiUrls.Home.UserAgent }, maxRedirects: 0, }) ); continue; } throw new Error(`Unexpected response in redirect chain: status=${hopStatus}, location=${hopLocation}`); } if (!authCode) throw new Error('Failed to extract auth code after redirect chain'); if (this.logDebug) this.emit('debug', `Got auth code: ${authCode.slice(0, 20)}...`); // ── Step 6: Exchange auth code for tokens ───────────────────────── const exchangeRes = await this.exchangeCodeForTokens(client, authCode, codeVerifier); return await this.buildConnectInfo(connectInfo, exchangeRes); } catch (error) { throw new Error(`Connect error: ${error.message}`); } } // ── Scenes ──────────────────────────────────────────────────────────────── async checkScenesList() { try { if (this.logDebug) this.emit('debug', 'Scanning for scenes'); const resp = await this.client.get(ApiUrls.Home.Get.Scenes); if (this.logDebug) this.emit('debug', `Scenes: ${JSON.stringify(resp.data, null, 2)}`); return this.capitalizeKeysDeep(resp.data); } catch (error) { throw new Error(`Check scenes list error: ${error.message}`); } } // ── Devices ─────────────────────────────────────────────────────────────── // Wraps checkDevicesList with a single retry on timeout or network error. // Prevents the plugin from restarting when a stale TCP socket causes a one-off hang. async checkDevicesListWithRetry() { try { return await this.checkDevicesList(); } catch (error) { const statusCode = error.response?.status; const isRetryable = error.message.includes('timeout') || error.message.includes('ECONNRESET') || error.message.includes('ECONNREFUSED') || error.message.includes('socket hang up') || statusCode === 404 || (statusCode >= 500 && statusCode <= 599); if (isRetryable) { if (this.logWarn) this.emit('warn', `checkDevicesList failed (${error.message}) — retrying once`); await new Promise(resolve => setTimeout(resolve, 3_000)); return await this.checkDevicesList(); } throw error; } } async checkDevicesList() { try { const result = { State: false, Status: null, Buildings: {}, Devices: [], Scenes: [] }; if (this.logDebug) this.emit('debug', 'Scanning for devices'); const resp = await this.client.get(ApiUrls.Home.Get.Context); const userContext = resp.data; const buildingsList = [ ...(userContext.buildings ?? []), ...(userContext.guestBuildings ?? []), ]; if (this.logDebug) this.emit('debug', `Buildings: ${JSON.stringify(buildingsList, null, 2)}`); if (buildingsList.length === 0) { result.Status = 'No buildings found'; return result; } // Shallow capitalize — used for flat objects (Capabilities, FrostProtection, etc.) const capitalizeKeys = obj => Object.fromEntries( Object.entries(obj).map(([k, v]) => [k.charAt(0).toUpperCase() + k.slice(1), v]) ); const createDevice = (device, type) => { const settingsObject = Object.fromEntries( (device.Settings || []).map(({ name, value }) => [ name.charAt(0).toUpperCase() + name.slice(1), this.functions.convertValue(value), ]) ); const deviceObject = { ...capitalizeKeys(device.Capabilities || {}), ...settingsObject, DeviceType: type, FirmwareAppVersion: device.ConnectedInterfaceIdentifier, IsConnected: device.IsConnected, }; if (device.FrostProtection) device.FrostProtection = capitalizeKeys(device.FrostProtection); if (device.OverheatProtection) device.OverheatProtection = capitalizeKeys(device.OverheatProtection); if (device.HolidayMode) device.HolidayMode = capitalizeKeys(device.HolidayMode); if (Array.isArray(device.Schedule)) { device.Schedule = device.Schedule.map(s => this.capitalizeKeysDeep(s)); } const { Settings, Capabilities, Id, GivenDisplayName, ...rest } = device; return { ...rest, Type: type, DeviceID: Id, DeviceName: GivenDisplayName, SerialNumber: Id, Device: deviceObject, }; }; const devices = buildingsList.flatMap(building => [ ...(building.airToAirUnits || []).map(d => createDevice(capitalizeKeys(d), 0)), ...(building.airToWaterUnits || []).map(d => createDevice(capitalizeKeys(d), 1)), ...(building.airToVentilationUnits || []).map(d => createDevice(capitalizeKeys(d), 3)), ]); if (devices.length === 0) { result.Status = 'No devices found'; return result; } let scenes = []; try { scenes = await this.checkScenesList(); if (this.logDebug) this.emit('debug', `Found ${scenes.length} scenes`); } catch (error) { if (this.logError) this.emit('error', `Get scenes error: ${error.message}`); } result.State = true; result.Status = `Found ${devices.length} devices${scenes.length > 0 ? ` and ${scenes.length} scenes` : ''}`; result.Buildings = userContext; result.Devices = devices; result.Scenes = scenes; for (const deviceData of result.Devices) { deviceData.Scenes = result.Scenes; this.emit(deviceData.DeviceID, 'request', deviceData); } return result; } catch (error) { throw new Error(`Check devices list error: ${error.message}`); } } } export default MelCloudHome;