homebridge-melcloud-control 4.10.0-beta.0 → 4.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -28,7 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
28
28
 
29
29
  ## Changes
30
30
 
31
- - added web socket for real time data refresh with MELCloud Home
31
+ - added web socket for real time data update with MELCloud Home
32
32
  - cleanup
33
33
 
34
34
  # [4.9.1] - (15.04.2026)
package/index.js CHANGED
@@ -90,7 +90,7 @@ class MelCloudPlatform {
90
90
  if (logLevel.warn) log.warn(`Unknown account type: ${account.type}.`);
91
91
  return;
92
92
  }
93
- melCloudClass.on('success', (msg) => logLevel.success && log.success(`${name}, ${msg}`))
93
+ melCloudClass.on('success', (msg) => log.success(`${name}, ${msg}`))
94
94
  .on('info', (msg) => log.info(`${name}, ${msg}`))
95
95
  .on('debug', (msg) => log.info(`${name}, debug: ${msg}`))
96
96
  .on('warn', (msg) => log.warn(`${name}, ${msg}`))
@@ -188,8 +188,8 @@ class MelCloudPlatform {
188
188
  continue;
189
189
  }
190
190
 
191
- deviceClass.on('devInfo', (info) => logLevel.devInfo && log.info(info))
192
- .on('success', (msg) => logLevel.success && log.success(`${name}, ${deviceTypeString}, ${deviceName}, ${msg}`))
191
+ deviceClass.on('devInfo', (info) => log.info(info))
192
+ .on('success', (msg) => log.success(`${name}, ${deviceTypeString}, ${deviceName}, ${msg}`))
193
193
  .on('info', (msg) => log.info(`${name}, ${deviceTypeString}, ${deviceName}, ${msg}`))
194
194
  .on('debug', (msg) => log.info(`${name}, ${deviceTypeString}, ${deviceName}, debug: ${msg}`))
195
195
  .on('warn', (msg) => log.warn(`${name}, ${deviceTypeString}, ${deviceName}, ${msg}`))
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "displayName": "MELCloud Control",
3
3
  "name": "homebridge-melcloud-control",
4
- "version": "4.10.0-beta.0",
4
+ "version": "4.10.0",
5
5
  "description": "Homebridge plugin to control Mitsubishi Air Conditioner, Heat Pump and Energy Recovery Ventilation.",
6
6
  "license": "MIT",
7
7
  "author": "grzegorz914",
package/src/melcloud.js CHANGED
@@ -10,6 +10,7 @@ class MelCloud extends EventEmitter {
10
10
  this.user = account.user;
11
11
  this.passwd = account.passwd;
12
12
  this.language = account.language;
13
+ this.logSuccess = account.log?.success;
13
14
  this.logWarn = account.log?.warn;
14
15
  this.logError = account.log?.error;
15
16
  this.logDebug = account.log?.debug;
@@ -26,7 +27,7 @@ class MelCloud extends EventEmitter {
26
27
  await this.checkDevicesList();
27
28
  }))
28
29
  .on('state', (state) => {
29
- this.emit(state ? 'success' : 'warn', `Impulse generator ${state ? 'started' : 'stopped'}`);
30
+ if (this.logSuccess) this.emit(state ? 'success' : 'warn', `Impulse generator ${state ? 'started' : 'stopped'}`);
30
31
  });
31
32
  }
32
33
  }
@@ -6,7 +6,6 @@ class MelCloudAta extends EventEmitter {
6
6
  constructor(account, device, defaultTempsFile, melCloudClass) {
7
7
  super();
8
8
  this.accountTypeMelcloud = account.type === 'melcloud';
9
- this.logSuccess = account.log?.success;
10
9
  this.logWarn = account.log?.warn;
11
10
  this.logError = account.log?.error;
12
11
  this.logDebug = account.log?.debug;
@@ -6,7 +6,6 @@ class MelCloudAtw extends EventEmitter {
6
6
  constructor(account, device, defaultTempsFile, melCloudClass) {
7
7
  super();
8
8
  this.accountTypeMelCloud = account.type === 'melcloud';
9
- this.logSuccess = account.log?.success;
10
9
  this.logWarn = account.log?.warn;
11
10
  this.logError = account.log?.error;
12
11
  this.logDebug = account.log?.debug;
@@ -242,7 +241,7 @@ class MelCloudAtw extends EventEmitter {
242
241
  case 'schedule':
243
242
  payload = { enabled: payload.enabled };
244
243
  method = 'PUT';
245
- path = ApiUrls.Home.Put.ScheduleEnableDisable.replace('deviceid', deviceData.DeviceID);
244
+ path = ApiUrls.Home.Put.ScheduleEnabled.replace('deviceid', deviceData.DeviceID);
246
245
  deviceData.ScheduleEnabled = payload.enabled;
247
246
  break;
248
247
  case 'scene':
@@ -6,7 +6,6 @@ class MelCloudErv extends EventEmitter {
6
6
  constructor(account, device, defaultTempsFile, melCloudClass) {
7
7
  super();
8
8
  this.accountTypeMelCloud = account.type === 'melcloud';
9
- this.logSuccess = account.log?.success;
10
9
  this.logWarn = account.log?.warn;
11
10
  this.logError = account.log?.error;
12
11
  this.logDebug = account.log?.debug;
@@ -245,7 +244,7 @@ class MelCloudErv extends EventEmitter {
245
244
  case 'schedule':
246
245
  payload = { enabled: payload.enabled };
247
246
  method = 'PUT';
248
- path = ApiUrls.Home.Put.ScheduleEnableDisable.replace('deviceid', deviceData.DeviceID);
247
+ path = ApiUrls.Home.Put.ScheduleEnabled.replace('deviceid', deviceData.DeviceID);
249
248
  deviceData.ScheduleEnabled = payload.enabled;
250
249
  break;
251
250
  case 'scene':
@@ -31,15 +31,15 @@ class MelCloudHome extends EventEmitter {
31
31
  this.pacer = new RequestPacer();
32
32
 
33
33
  // Axios clients
34
- this.authClient = null; // cookie-jar client używany tylko podczas auth flow
35
- this.client = null; // API client używany do requestów po zalogowaniu
34
+ this.authClient = null; // cookie-jar client used only during the auth flow
35
+ this.client = null; // API client used for all post-login requests
36
36
 
37
37
  // Token state
38
38
  this.accessToken = null;
39
39
  this.refreshToken = null;
40
- this.tokenExpiry = 0; // Unix timestamp (sekundy)
40
+ this.tokenExpiry = 0; // Unix timestamp (seconds)
41
41
 
42
- // Flaga zapobiegająca wielokrotnemu dodaniu interceptorów
42
+ // Flag preventing duplicate interceptor registration on re-login
43
43
  this.interceptorsAttached = false;
44
44
 
45
45
  // WebSocket state
@@ -48,8 +48,8 @@ class MelCloudHome extends EventEmitter {
48
48
  this.connecting = false;
49
49
  this.heartbeat = null;
50
50
  this.reconnectTimer = null;
51
- this.reconnectDelay = 5_000; // ms, rośnie wykładniczo do reconnectDelayMax
52
- this.reconnectDelayMax = 300_000; // 5 minut
51
+ this.reconnectDelay = 5_000; // ms, grows exponentially up to reconnectDelayMax
52
+ this.reconnectDelayMax = 300_000; // 5 minutes
53
53
 
54
54
  if (pluginStart) {
55
55
  this.impulseGenerator = new ImpulseGenerator()
@@ -64,17 +64,19 @@ class MelCloudHome extends EventEmitter {
64
64
 
65
65
  // ── WebSocket ─────────────────────────────────────────────────────────────
66
66
 
67
+ // Resets all WebSocket state and clears the heartbeat interval.
67
68
  cleanupSocket() {
68
69
  if (this.heartbeat) {
69
70
  clearInterval(this.heartbeat);
70
71
  this.heartbeat = null;
71
72
  }
73
+ this.socket = null;
72
74
  this.socketConnected = false;
73
75
  this.connecting = false;
74
- this.socket = null;
75
76
  }
76
77
 
77
- // Łączy się z WebSocket. Wywoływane po udanym connect() lub po reconnect.
78
+ // Opens a WebSocket connection using the user ID from /api/user/context as the hash.
79
+ // Called automatically after a successful login and on every reconnect attempt.
78
80
  async connectSocket() {
79
81
  if (this.connecting || this.socketConnected) return;
80
82
  this.connecting = true;
@@ -82,9 +84,10 @@ class MelCloudHome extends EventEmitter {
82
84
  let hash;
83
85
  try {
84
86
  const resp = await this.client.get(ApiUrls.Home.Get.Context);
85
- hash = resp.data.id ?? null;
87
+ hash = resp.data?.id ?? null;
88
+ if (!hash) throw new Error('id field missing in context response');
86
89
  } catch (err) {
87
- if (this.logError) this.emit('error', `connectSocket: cannot get WS hash: ${err.message}`);
90
+ if (this.logError) this.emit('error', `WebSocket: cannot get hash: ${err.message}`);
88
91
  this.connecting = false;
89
92
  this.scheduleReconnect();
90
93
  return;
@@ -97,68 +100,74 @@ class MelCloudHome extends EventEmitter {
97
100
  'Cache-Control': 'no-cache',
98
101
  };
99
102
 
100
- if (this.logDebug) this.emit('debug', `Connecting WebSocket: ${url.slice(0, 60)}...`);
103
+ if (this.logDebug) this.emit('debug', `WebSocket connecting: ${url.slice(0, 60)}...`);
101
104
 
102
105
  try {
103
106
  const ws = new WebSocket(url, { headers });
104
107
  this.socket = ws;
105
108
 
106
109
  ws.on('error', (error) => {
107
- if (this.logError) this.emit('error', `Web socket error: ${error.message}`);
108
- try { ws.close(); } catch { /* ignoruj */ }
109
- })
110
- .on('close', () => {
111
- if (this.logDebug) this.emit('debug', 'Web socket closed');
112
- this.cleanupSocket();
113
- this.scheduleReconnect();
114
- })
115
- .on('open', () => {
116
- this.socketConnected = true;
117
- this.connecting = false;
118
- this.reconnectDelay = 5_000; // reset backoff po udanym połączeniu
119
- if (this.reconnectTimer) {
120
- clearTimeout(this.reconnectTimer);
121
- this.reconnectTimer = null;
110
+ if (this.logError) this.emit('error', `WebSocket error: ${error.message}`);
111
+ try { ws.close(); } catch { /* ignore if already closed */ }
112
+ });
113
+
114
+ ws.on('close', () => {
115
+ if (this.logDebug) this.emit('debug', 'WebSocket closed');
116
+ this.cleanupSocket();
117
+ this.scheduleReconnect();
118
+ });
119
+
120
+ ws.on('open', () => {
121
+ this.socketConnected = true;
122
+ this.connecting = false;
123
+ this.reconnectDelay = 5_000; // reset backoff on successful connection
124
+ if (this.reconnectTimer) {
125
+ clearTimeout(this.reconnectTimer);
126
+ this.reconnectTimer = null;
127
+ }
128
+ if (this.logSuccess) this.emit('success', 'WebSocket connected');
129
+
130
+ // Send a ping every 30 s to keep the connection alive
131
+ this.heartbeat = setInterval(() => {
132
+ if (ws.readyState === WebSocket.OPEN) {
133
+ if (this.logDebug) this.emit('debug', 'WebSocket heartbeat sent');
134
+ ws.ping();
122
135
  }
123
- if (this.logSuccess) this.emit('success', 'Web Socket Connected');
124
-
125
- // Heartbeat co 30s
126
- this.heartbeat = setInterval(() => {
127
- if (ws.readyState === WebSocket.OPEN) {
128
- if (this.logDebug) this.emit('debug', 'Web socket send heartbeat');
129
- ws.ping();
130
- }
131
- }, 30_000);
132
- })
133
- .on('pong', () => {
134
- if (this.logDebug) this.emit('debug', 'Web socket received heartbeat');
135
- })
136
- .on('message', (message) => {
137
- try {
138
- const parsedMessage = JSON.parse(message);
139
- if (this.logDebug) this.emit('debug', `Web socket incoming message: ${JSON.stringify(parsedMessage, null, 2)}`);
136
+ }, 30_000);
137
+ });
140
138
 
141
- // Format: array, pierwszy element ma Data.id
142
- const messageData = parsedMessage?.[0]?.Data;
143
- if (!messageData || parsedMessage.message === 'Forbidden') return;
139
+ ws.on('pong', () => {
140
+ if (this.logDebug) this.emit('debug', 'WebSocket heartbeat received');
141
+ });
142
+
143
+ ws.on('message', (message) => {
144
+ try {
145
+ const parsed = JSON.parse(message);
146
+ const messageData = parsed?.[0]?.Data;
147
+
148
+ if (this.logDebug) this.emit('debug', `WebSocket message: ${JSON.stringify(parsed, null, 2)}`);
149
+
150
+ // Ignore empty payloads and server-side auth errors
151
+ if (!messageData || parsed.message === 'Forbidden') return;
152
+
153
+ this.emit(messageData.id, 'ws', parsed[0]);
154
+ } catch (err) {
155
+ if (this.logError) this.emit('error', `WebSocket message parse error: ${err.message}`);
156
+ }
157
+ });
144
158
 
145
- this.emit(messageData.id, 'ws', parsedMessage[0]);
146
- } catch (err) {
147
- if (this.logError) this.emit('error', `Web socket message parse error: ${err.message}`);
148
- }
149
- });
150
159
  } catch (error) {
151
- if (this.logError) this.emit('error', `Web socket connection failed: ${error.message}`);
160
+ if (this.logError) this.emit('error', `WebSocket connection failed: ${error.message}`);
152
161
  this.cleanupSocket();
153
162
  this.scheduleReconnect();
154
163
  }
155
164
  }
156
165
 
157
- // Wykładniczy backoff: 5s10s 20s ...max 5 minut
166
+ // Schedules a reconnect attempt using exponential backoff (5 s 10 s → 5 min).
158
167
  scheduleReconnect() {
159
- if (this.reconnectTimer) return; // już zaplanowany
168
+ if (this.reconnectTimer) return; // already scheduled
160
169
 
161
- if (this.logDebug) this.emit('debug', `Web socket reconnecting in ${this.reconnectDelay / 1000}s...`);
170
+ if (this.logDebug) this.emit('debug', `WebSocket reconnecting in ${this.reconnectDelay / 1000} s...`);
162
171
 
163
172
  this.reconnectTimer = setTimeout(async () => {
164
173
  this.reconnectTimer = null;
@@ -170,6 +179,7 @@ class MelCloudHome extends EventEmitter {
170
179
 
171
180
  // ── Utils ─────────────────────────────────────────────────────────────────
172
181
 
182
+ // Recursively capitalizes the first letter of every object key.
173
183
  capitalizeKeysDeep(obj) {
174
184
  if (Array.isArray(obj)) return obj.map(item => this.capitalizeKeysDeep(item));
175
185
  if (obj && typeof obj === 'object') {
@@ -185,6 +195,7 @@ class MelCloudHome extends EventEmitter {
185
195
 
186
196
  // ── Token state ───────────────────────────────────────────────────────────
187
197
 
198
+ // Returns true when the access token is absent or expires within 60 seconds.
188
199
  isTokenExpired() {
189
200
  if (!this.accessToken) return true;
190
201
  return Date.now() / 1000 >= this.tokenExpiry - 60;
@@ -192,11 +203,12 @@ class MelCloudHome extends EventEmitter {
192
203
 
193
204
  // ── Axios clients ─────────────────────────────────────────────────────────
194
205
 
206
+ // Returns (creating if needed) the cookie-jar client used during the OAuth flow.
195
207
  ensureAuthClient() {
196
208
  if (this.authClient) return this.authClient;
197
209
 
198
210
  const jar = new CookieJar();
199
- const instance = wrapper(
211
+ this.authClient = wrapper(
200
212
  axios.create({
201
213
  jar,
202
214
  timeout: 30_000,
@@ -205,14 +217,14 @@ class MelCloudHome extends EventEmitter {
205
217
  'User-Agent': ApiUrls.Home.UserAgent,
206
218
  },
207
219
  maxRedirects: 5,
208
- validateStatus: () => true,
220
+ validateStatus: () => true, // handle all status codes manually
209
221
  })
210
222
  );
211
223
 
212
- this.authClient = instance;
213
- return instance;
224
+ return this.authClient;
214
225
  }
215
226
 
227
+ // Returns (creating if needed) the API client used for all post-login requests.
216
228
  ensureClient() {
217
229
  if (this.client) return this.client;
218
230
 
@@ -228,7 +240,7 @@ class MelCloudHome extends EventEmitter {
228
240
  return this.client;
229
241
  }
230
242
 
231
- // ── Pacer helper ──────────────────────────────────────────────────────────
243
+ // ── Pacer ─────────────────────────────────────────────────────────────────
232
244
 
233
245
  pace(fn) {
234
246
  return this.pacer.run(fn);
@@ -244,6 +256,7 @@ class MelCloudHome extends EventEmitter {
244
256
 
245
257
  // ── CSRF token ────────────────────────────────────────────────────────────
246
258
 
259
+ // Extracts the _csrf token value from the Cognito login page HTML.
247
260
  extractCsrfToken(html) {
248
261
  return (
249
262
  /<input[^>]+name="_csrf"[^>]+value="([^"]+)"/.exec(html)?.[1] ??
@@ -253,8 +266,9 @@ class MelCloudHome extends EventEmitter {
253
266
  );
254
267
  }
255
268
 
256
- // ── Follow callback redirect ──────────────────────────────────────────────
269
+ // ── OAuth helpers ─────────────────────────────────────────────────────────
257
270
 
271
+ // Follows the /connect/authorize/callback redirect chain and returns the auth code.
258
272
  async followCallbackForCode(client, callbackQs) {
259
273
  const qs = callbackQs.replace(/&amp;/g, '&');
260
274
  const callbackUrl = `${ApiUrls.Home.AuthBase}/connect/authorize/callback?${qs}`;
@@ -272,10 +286,9 @@ class MelCloudHome extends EventEmitter {
272
286
  if (m) return m[1];
273
287
  }
274
288
 
275
- if (!location || location === '/')
276
- throw new Error('Callback returned empty or root redirect');
289
+ if (!location || location === '/') throw new Error('Callback returned empty or root redirect');
277
290
 
278
- // Jeden dodatkowy hop
291
+ // One additional hop if needed
279
292
  const redirectUrl = location.startsWith('http')
280
293
  ? location
281
294
  : `${ApiUrls.Home.AuthBase}${location}`;
@@ -293,8 +306,7 @@ class MelCloudHome extends EventEmitter {
293
306
  return m[1];
294
307
  }
295
308
 
296
- // ── Token exchange ────────────────────────────────────────────────────────
297
-
309
+ // Exchanges an authorization code for access and refresh tokens.
298
310
  async exchangeCodeForTokens(client, authCode, codeVerifier) {
299
311
  if (this.logDebug) this.emit('debug', 'Step 6: Token exchange');
300
312
 
@@ -325,11 +337,11 @@ class MelCloudHome extends EventEmitter {
325
337
 
326
338
  // ── Token refresh ─────────────────────────────────────────────────────────
327
339
 
340
+ // Uses the stored refresh token to obtain a new access token.
328
341
  async refreshAccessToken() {
329
342
  if (!this.refreshToken) throw new Error('No refresh token available');
330
343
 
331
344
  const client = this.ensureAuthClient();
332
-
333
345
  const resp = await this.pace(() =>
334
346
  client.post(
335
347
  `${ApiUrls.Home.AuthBase}/connect/token`,
@@ -354,57 +366,66 @@ class MelCloudHome extends EventEmitter {
354
366
  return true;
355
367
  }
356
368
 
357
- // ── Auto-refresh: refresh token lub pełne logowanie od nowa ──────────────
358
-
369
+ // Attempts a token refresh; falls back to a full re-login if the refresh token is
370
+ // missing or rejected. A single shared Promise prevents concurrent refresh races.
359
371
  async refreshOrRelogin() {
360
- if (this.refreshToken) {
361
- try {
362
- await this.refreshAccessToken();
363
- if (this.logDebug) this.emit('debug', 'Token refreshed successfully');
364
- return;
365
- } catch (err) {
366
- if (this.logDebug) this.emit('debug', `Refresh token rejected (${err.message}), falling back to full re-login`);
372
+ if (this.refreshPromise) return this.refreshPromise;
373
+
374
+ this.refreshPromise = (async () => {
375
+ if (this.refreshToken) {
376
+ try {
377
+ await this.refreshAccessToken();
378
+ if (this.logDebug) this.emit('debug', 'Token refreshed successfully');
379
+ return;
380
+ } catch (err) {
381
+ if (this.logDebug) this.emit('debug', `Refresh token rejected (${err.message}), falling back to full re-login`);
382
+ }
367
383
  }
368
- }
369
384
 
370
- if (this.logDebug) this.emit('debug', 'Performing full re-login');
371
- await this.connect();
385
+ if (this.logDebug) this.emit('debug', 'Performing full re-login');
386
+ await this.connect();
387
+ })().finally(() => {
388
+ this.refreshPromise = null;
389
+ });
390
+
391
+ return this.refreshPromise;
372
392
  }
373
393
 
374
- // ── Interceptory do automatycznego odświeżania tokena ─────────────────────
394
+ // ── Token interceptors ────────────────────────────────────────────────────
375
395
 
396
+ // Attaches request and response interceptors to the API client.
397
+ // Safe to call multiple times — interceptors are registered only once.
376
398
  attachTokenInterceptors() {
377
399
  if (this.interceptorsAttached) return;
378
400
  this.interceptorsAttached = true;
379
401
 
380
402
  const apiClient = this.ensureClient();
381
403
 
382
- // Request interceptor dokłada aktualny token przed każdym requestem.
383
- // Jeśli token wygasł, odświeża go najpierw.
404
+ // Inject a fresh Authorization header before every request.
405
+ // If the token is expired, refresh it first.
384
406
  apiClient.interceptors.request.use(async (config) => {
385
407
  if (this.isTokenExpired()) {
386
- if (this.logDebug) this.emit('debug', 'Token expired or missing — refreshing before request');
408
+ if (this.logDebug) this.emit('debug', 'Token expired — refreshing before request');
387
409
  await this.refreshOrRelogin();
388
410
  }
389
411
  config.headers['Authorization'] = `Bearer ${this.accessToken}`;
390
412
  return config;
391
413
  });
392
414
 
393
- // Response interceptor obsługuje 401 który może przyjść mimo świeżego tokena
394
- // (np. token odwołany po stronie serwera). Ponawia request dokładnie raz.
415
+ // On 401, refresh the token and retry the original request exactly once.
395
416
  apiClient.interceptors.response.use(
396
417
  response => response,
397
418
  async (error) => {
398
- const originalRequest = error.config;
419
+ const original = error.config;
399
420
 
400
- if (error.response?.status === 401 && !originalRequest._retried) {
401
- originalRequest._retried = true;
402
- if (this.logDebug) this.emit('debug', 'Got 401 — refreshing token and retrying request');
421
+ if (error.response?.status === 401 && !original.retried) {
422
+ original.retried = true;
423
+ if (this.logDebug) this.emit('debug', 'Got 401 — refreshing token and retrying');
403
424
 
404
425
  try {
405
426
  await this.refreshOrRelogin();
406
- originalRequest.headers['Authorization'] = `Bearer ${this.accessToken}`;
407
- return apiClient(originalRequest);
427
+ original.headers['Authorization'] = `Bearer ${this.accessToken}`;
428
+ return apiClient(original);
408
429
  } catch (refreshError) {
409
430
  this.emit('error', `Token refresh failed: ${refreshError.message}`);
410
431
  return Promise.reject(refreshError);
@@ -416,19 +437,19 @@ class MelCloudHome extends EventEmitter {
416
437
  );
417
438
  }
418
439
 
419
- // ── Buduje connectInfo po udanym token exchange ───────────────────────────
440
+ // ── Post-login setup ──────────────────────────────────────────────────────
420
441
 
442
+ // Finalises the connect flow: sets up the API client, attaches interceptors,
443
+ // emits the 'client' event and opens the WebSocket connection.
421
444
  async buildConnectInfo(connectInfo, exchangeRes) {
422
445
  if (exchangeRes) {
423
- // ensureClient() tworzy client jeśli nie istnieje.
424
- // attachTokenInterceptors() dodaje interceptory tylko przy pierwszym wywołaniu.
425
446
  this.ensureClient();
426
447
  this.attachTokenInterceptors();
427
448
 
428
449
  if (this.pluginStart) {
429
450
  this.emit('client', this.client);
430
451
  await this.connectSocket().catch(err => {
431
- if (this.logError) this.emit('error', `Initial WebSocket connect failed: ${err.message}`);
452
+ if (this.logError) this.emit('error', `WebSocket initial connect failed: ${err.message}`);
432
453
  });
433
454
  }
434
455
  }
@@ -441,17 +462,23 @@ class MelCloudHome extends EventEmitter {
441
462
 
442
463
  // ── Connect ───────────────────────────────────────────────────────────────
443
464
 
465
+ // Full OAuth 2.0 PKCE login flow:
466
+ // Step 1 — Pushed Authorization Request (PAR)
467
+ // Step 2 — Authorize redirect → Cognito login page (or fast-path if session exists)
468
+ // Step 3 — POST credentials to Cognito (maxRedirects: 0 to intercept form_post)
469
+ // Step 4 — POST Cognito callback params to IdentityServer /signin-oidc-meu
470
+ // Step 5 — Follow redirect chain until the auth code is found
471
+ // Step 6 — Exchange auth code for access + refresh tokens
444
472
  async connect() {
445
473
  if (this.logDebug) this.emit('debug', 'Connecting to MELCloud Home');
446
474
 
447
475
  try {
448
476
  const connectInfo = { State: false, Status: '', Account: {}, UseFahrenheit: false };
449
-
450
477
  const client = this.ensureAuthClient();
451
478
  const { verifier: codeVerifier, challenge: codeChallenge } = this.generatePkce();
452
479
  const state = crypto.randomBytes(16).toString('base64url');
453
480
 
454
- // ── Step 1: PAR ──────────────────────────────────────────────────
481
+ // ── Step 1: PAR ───────────────────────────────────────────────────
455
482
  if (this.logDebug) this.emit('debug', 'Step 1: PAR request');
456
483
 
457
484
  const parResp = await this.pace(() =>
@@ -498,18 +525,16 @@ class MelCloudHome extends EventEmitter {
498
525
 
499
526
  const finalUrl = authResp.request?.res?.responseUrl ?? authorizeUrl;
500
527
  const parsed = new URL(finalUrl);
501
- const body = typeof authResp.data === 'string'
502
- ? authResp.data
503
- : JSON.stringify(authResp.data);
528
+ const body = typeof authResp.data === 'string' ? authResp.data : JSON.stringify(authResp.data);
504
529
 
505
530
  if (parsed.hostname?.endsWith(ApiUrls.Home.CognitoDomainSuffix) && parsed.pathname.includes('/login')) {
506
- // Happy path: strona logowania Cognito
531
+ // Happy path: landed on the Cognito login page
507
532
  csrfToken = this.extractCsrfToken(body);
508
533
  if (!csrfToken) throw new Error('Failed to extract CSRF token from Cognito login page');
509
534
  cognitoLoginUrl = finalUrl;
510
535
  if (this.logDebug) this.emit('debug', 'Cognito login page OK');
511
536
  } else {
512
- // Fast path: istniejąca sesjakod dostępny od razu
537
+ // Fast path: existing IdentityServer session auth code available immediately
513
538
  const codeMatch = /code=([^&"' ]+)/.exec(finalUrl) || /code=([^&"' ]+)/.exec(body);
514
539
  if (codeMatch) {
515
540
  authCode = codeMatch[1];
@@ -525,20 +550,21 @@ class MelCloudHome extends EventEmitter {
525
550
  }
526
551
  }
527
552
 
528
- // Fast-path: pomiń etap logowania
553
+ // Skip credential submission when we already have a code
529
554
  if (authCode) {
530
555
  if (this.logDebug) this.emit('debug', 'Re-login with existing session (skipping credentials)');
531
556
  const exchangeRes = await this.exchangeCodeForTokens(client, authCode, codeVerifier);
532
557
  return await this.buildConnectInfo(connectInfo, exchangeRes);
533
558
  }
534
559
 
535
- // ── Step 3: Wyślij dane logowania do Cognito ──────────────────────
560
+ // ── Step 3: Submit credentials to Cognito ─────────────────────────
561
+ // maxRedirects: 0 — Cognito uses response_mode=form_post, so after a
562
+ // successful login it POSTs back to IdentityServer (/signin-oidc-meu).
563
+ // We intercept the 302 before axios follows it to avoid a 500 from Kestrel.
536
564
  if (this.logDebug) this.emit('debug', 'Step 3: Submit credentials to Cognito');
537
565
 
538
566
  const cognitoHostname = new URL(cognitoLoginUrl).hostname;
539
567
 
540
- // maxRedirects: 0 — Cognito używa response_mode=form_post.
541
- // Przechwytujemy 302 zanim axios podąży za nim do IdentityServera (→ 500).
542
568
  const credResp = await this.pace(() =>
543
569
  client.post(
544
570
  cognitoLoginUrl,
@@ -565,13 +591,14 @@ class MelCloudHome extends EventEmitter {
565
591
  this.emit('debug', `Step 3 response location: ${credResp.headers?.location ?? '(none)'}`);
566
592
  }
567
593
 
568
- // status 200 = zostaliśmy na stronie Cognitozłe hasło
594
+ // HTTP 200 means Cognito returned the login page again wrong password
569
595
  if (credResp.status === 200) throw new Error('Authentication failed: Invalid username or password');
570
596
  if (credResp.status >= 500) throw new Error(`Cognito server error: HTTP ${credResp.status}`);
571
597
 
572
- // ── Step 4: POST do signin-oidc-meu (emulacja form_post z Cognito) ──
573
- // Cognito normalnie robi POST z code+state w body do IdentityServera.
574
- // My dostaliśmy 302 z tymi parametrami w query wysyłamy je jako POST body.
598
+ // ── Step 4: POST Cognito callback params to IdentityServer ─────────
599
+ // Cognito normally POSTs code+state to /signin-oidc-meu (form_post).
600
+ // We received a 302 with those params in the query string, so we replay
601
+ // them as a POST body — exactly as Cognito would have done.
575
602
  if (this.logDebug) this.emit('debug', 'Step 4: Follow Cognito → IdentityServer redirect');
576
603
 
577
604
  const cognitoRedirectLocation = credResp.headers?.location ?? '';
@@ -600,9 +627,9 @@ class MelCloudHome extends EventEmitter {
600
627
  this.emit('debug', `Step 4 signin location: ${signinResp.headers?.location ?? '(none)'}`);
601
628
  }
602
629
 
603
- // ── Step 5: Podążaj za łańcuchem redirectów do auth code ──────────
604
- // IdentityServer przekierowuje przez kilka etapów:
605
- // /ExternalLogin/Callback → /connect/authorize/callback → melcloudhome://
630
+ // ── Step 5: Follow redirect chain until the auth code is found ────
631
+ // IdentityServer redirects through several hops:
632
+ // /ExternalLogin/Callback → /connect/authorize/callback → melcloudhome://
606
633
  if (this.logDebug) this.emit('debug', 'Step 5: Following redirect chain to auth code');
607
634
 
608
635
  let currentResp = signinResp;
@@ -615,13 +642,13 @@ class MelCloudHome extends EventEmitter {
615
642
 
616
643
  if (this.logDebug) this.emit('debug', `Step 5 hop ${hop}: status=${hopStatus} location=${hopLocation || '(none)'}`);
617
644
 
618
- // A: melcloudhome:// z code=
645
+ // A: custom scheme redirect carrying the auth code
619
646
  if (hopLocation.startsWith('melcloudhome://')) {
620
647
  const m = /code=([^&"' ]+)/.exec(hopLocation);
621
648
  if (m) { authCode = m[1]; break; }
622
649
  }
623
650
 
624
- // B: /connect/authorize/callback w location lub body
651
+ // B: IdentityServer authorize callback delegate to helper
625
652
  const cbMatch = /\/connect\/authorize\/callback\?([^"' ]+)/.exec(hopLocation)
626
653
  || /\/connect\/authorize\/callback\?([^"' ]+)/.exec(hopBody);
627
654
  if (cbMatch) {
@@ -630,15 +657,15 @@ class MelCloudHome extends EventEmitter {
630
657
  break;
631
658
  }
632
659
 
633
- // C: code= bezpośrednio w location
660
+ // C: auth code directly in the Location header
634
661
  const codeInLocation = /code=([^&"' ]+)/.exec(hopLocation);
635
662
  if (codeInLocation) { authCode = codeInLocation[1]; break; }
636
663
 
637
- // D: code= w body
664
+ // D: auth code in the response body
638
665
  const codeInBody = /code=([^&"' ]+)/.exec(hopBody);
639
666
  if (codeInBody) { authCode = codeInBody[1]; break; }
640
667
 
641
- // Zwykły redirect — podążaj dalej
668
+ // Standard redirect — follow the next hop
642
669
  if ((hopStatus === 301 || hopStatus === 302 || hopStatus === 303) && hopLocation) {
643
670
  const nextUrl = hopLocation.startsWith('http')
644
671
  ? hopLocation
@@ -660,7 +687,7 @@ class MelCloudHome extends EventEmitter {
660
687
 
661
688
  if (this.logDebug) this.emit('debug', `Got auth code: ${authCode.slice(0, 20)}...`);
662
689
 
663
- // ── Step 6: Wymień kod na tokeny ──────────────────────────────────
690
+ // ── Step 6: Exchange auth code for tokens ─────────────────────────
664
691
  const exchangeRes = await this.exchangeCodeForTokens(client, authCode, codeVerifier);
665
692
  return await this.buildConnectInfo(connectInfo, exchangeRes);
666
693
 
@@ -669,23 +696,23 @@ class MelCloudHome extends EventEmitter {
669
696
  }
670
697
  }
671
698
 
672
- // ── Scenes & Devices ──────────────────────────────────────────────────────
699
+ // ── Scenes ────────────────────────────────────────────────────────────────
673
700
 
674
701
  async checkScenesList() {
675
702
  try {
676
703
  if (this.logDebug) this.emit('debug', 'Scanning for scenes');
677
704
 
678
705
  const resp = await this.client.get(ApiUrls.Home.Get.Scenes);
679
- const scenesList = resp.data;
706
+ if (this.logDebug) this.emit('debug', `Scenes: ${JSON.stringify(resp.data, null, 2)}`);
680
707
 
681
- if (this.logDebug) this.emit('debug', `Scenes: ${JSON.stringify(scenesList, null, 2)}`);
682
-
683
- return this.capitalizeKeysDeep(scenesList);
708
+ return this.capitalizeKeysDeep(resp.data);
684
709
  } catch (error) {
685
710
  throw new Error(`Check scenes list error: ${error.message}`);
686
711
  }
687
712
  }
688
713
 
714
+ // ── Devices ───────────────────────────────────────────────────────────────
715
+
689
716
  async checkDevicesList() {
690
717
  try {
691
718
  const result = { State: false, Status: null, Buildings: {}, Devices: [], Scenes: [] };
@@ -693,11 +720,11 @@ class MelCloudHome extends EventEmitter {
693
720
 
694
721
  const resp = await this.client.get(ApiUrls.Home.Get.Context);
695
722
  const userContext = resp.data;
696
- //if (this.logDebug) this.emit('debug', `User Context: ${JSON.stringify(userContext, null, 2)}`);
697
723
 
698
- const buildings = userContext.buildings ?? [];
699
- const guestBuildings = userContext.guestBuildings ?? [];
700
- const buildingsList = [...buildings, ...guestBuildings];
724
+ const buildingsList = [
725
+ ...(userContext.buildings ?? []),
726
+ ...(userContext.guestBuildings ?? []),
727
+ ];
701
728
 
702
729
  if (this.logDebug) this.emit('debug', `Buildings: ${JSON.stringify(buildingsList, null, 2)}`);
703
730
 
@@ -706,6 +733,7 @@ class MelCloudHome extends EventEmitter {
706
733
  return result;
707
734
  }
708
735
 
736
+ // Shallow capitalize — used for flat objects (Capabilities, FrostProtection, etc.)
709
737
  const capitalizeKeys = obj => Object.fromEntries(
710
738
  Object.entries(obj).map(([k, v]) => [k.charAt(0).toUpperCase() + k.slice(1), v])
711
739
  );
@@ -729,7 +757,9 @@ class MelCloudHome extends EventEmitter {
729
757
  if (device.FrostProtection) device.FrostProtection = capitalizeKeys(device.FrostProtection);
730
758
  if (device.OverheatProtection) device.OverheatProtection = capitalizeKeys(device.OverheatProtection);
731
759
  if (device.HolidayMode) device.HolidayMode = capitalizeKeys(device.HolidayMode);
732
- if (Array.isArray(device.Schedule)) device.Schedule = device.Schedule.map(s => this.capitalizeKeysDeep(s));
760
+ if (Array.isArray(device.Schedule)) {
761
+ device.Schedule = device.Schedule.map(s => this.capitalizeKeysDeep(s));
762
+ }
733
763
 
734
764
  const { Settings, Capabilities, Id, GivenDisplayName, ...rest } = device;
735
765
 
@@ -754,13 +784,12 @@ class MelCloudHome extends EventEmitter {
754
784
  return result;
755
785
  }
756
786
 
757
- // Sceny
758
787
  let scenes = [];
759
788
  try {
760
789
  scenes = await this.checkScenesList();
761
790
  if (this.logDebug) this.emit('debug', `Found ${scenes.length} scenes`);
762
791
  } catch (error) {
763
- if (this.logError) this.emit('error', `Get scenes error: ${error}`);
792
+ if (this.logError) this.emit('error', `Get scenes error: ${error.message}`);
764
793
  }
765
794
 
766
795
  result.State = true;