homebridge-melcloud-control 4.9.2-beta.8 → 4.10.0-beta.1

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.9.2-beta.8",
4
+ "version": "4.10.0-beta.1",
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
- this.emit('client', this.client);
428
448
 
429
449
  if (this.pluginStart) {
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
  }
@@ -439,130 +460,25 @@ class MelCloudHome extends EventEmitter {
439
460
  return connectInfo;
440
461
  }
441
462
 
442
- // ── Scenes & Devices ──────────────────────────────────────────────────────
443
-
444
- async checkScenesList() {
445
- try {
446
- if (this.logDebug) this.emit('debug', 'Scanning for scenes');
447
-
448
- const resp = await this.client.get(ApiUrls.Home.Get.Scenes);
449
- const scenesList = resp.data;
450
-
451
- if (this.logDebug) this.emit('debug', `Scenes: ${JSON.stringify(scenesList, null, 2)}`);
452
-
453
- return this.capitalizeKeysDeep(scenesList);
454
- } catch (error) {
455
- throw new Error(`Check scenes list error: ${error.message}`);
456
- }
457
- }
458
-
459
- async checkDevicesList() {
460
- try {
461
- const result = { State: false, Status: null, Buildings: {}, Devices: [], Scenes: [] };
462
- if (this.logDebug) this.emit('debug', 'Scanning for devices');
463
-
464
- const resp = await this.client.get(ApiUrls.Home.Get.Context);
465
- const userContext = resp.data;
466
- //if (this.logDebug) this.emit('debug', `User Context: ${JSON.stringify(userContext, null, 2)}`);
467
-
468
- const buildings = userContext.buildings ?? [];
469
- const guestBuildings = userContext.guestBuildings ?? [];
470
- const buildingsList = [...buildings, ...guestBuildings];
471
-
472
- if (this.logDebug) this.emit('debug', `Buildings: ${JSON.stringify(buildingsList, null, 2)}`);
473
-
474
- if (buildingsList.length === 0) {
475
- result.Status = 'No buildings found';
476
- return result;
477
- }
478
-
479
- const capitalizeKeys = obj => Object.fromEntries(
480
- Object.entries(obj).map(([k, v]) => [k.charAt(0).toUpperCase() + k.slice(1), v])
481
- );
482
-
483
- const createDevice = (device, type) => {
484
- const settingsObject = Object.fromEntries(
485
- (device.Settings || []).map(({ name, value }) => [
486
- name.charAt(0).toUpperCase() + name.slice(1),
487
- this.functions.convertValue(value),
488
- ])
489
- );
490
-
491
- const deviceObject = {
492
- ...capitalizeKeys(device.Capabilities || {}),
493
- ...settingsObject,
494
- DeviceType: type,
495
- FirmwareAppVersion: device.ConnectedInterfaceIdentifier,
496
- IsConnected: device.IsConnected,
497
- };
498
-
499
- if (device.FrostProtection) device.FrostProtection = capitalizeKeys(device.FrostProtection);
500
- if (device.OverheatProtection) device.OverheatProtection = capitalizeKeys(device.OverheatProtection);
501
- if (device.HolidayMode) device.HolidayMode = capitalizeKeys(device.HolidayMode);
502
- if (Array.isArray(device.Schedule)) device.Schedule = device.Schedule.map(s => this.capitalizeKeysDeep(s));
503
-
504
- const { Settings, Capabilities, Id, GivenDisplayName, ...rest } = device;
505
-
506
- return {
507
- ...rest,
508
- Type: type,
509
- DeviceID: Id,
510
- DeviceName: GivenDisplayName,
511
- SerialNumber: Id,
512
- Device: deviceObject,
513
- };
514
- };
515
-
516
- const devices = buildingsList.flatMap(building => [
517
- ...(building.airToAirUnits || []).map(d => createDevice(capitalizeKeys(d), 0)),
518
- ...(building.airToWaterUnits || []).map(d => createDevice(capitalizeKeys(d), 1)),
519
- ...(building.airToVentilationUnits || []).map(d => createDevice(capitalizeKeys(d), 3)),
520
- ]);
521
-
522
- if (devices.length === 0) {
523
- result.Status = 'No devices found';
524
- return result;
525
- }
526
-
527
- // Sceny
528
- let scenes = [];
529
- try {
530
- scenes = await this.checkScenesList();
531
- if (this.logDebug) this.emit('debug', `Found ${scenes.length} scenes`);
532
- } catch (error) {
533
- if (this.logError) this.emit('error', `Get scenes error: ${error}`);
534
- }
535
-
536
- result.State = true;
537
- result.Status = `Found ${devices.length} devices${scenes.length > 0 ? ` and ${scenes.length} scenes` : ''}`;
538
- result.Buildings = userContext;
539
- result.Devices = devices;
540
- result.Scenes = scenes;
541
-
542
- for (const deviceData of result.Devices) {
543
- deviceData.Scenes = result.Scenes;
544
- this.emit(deviceData.DeviceID, 'request', deviceData);
545
- }
546
-
547
- return result;
548
- } catch (error) {
549
- throw new Error(`Check devices list error: ${error.message}`);
550
- }
551
- }
552
-
553
463
  // ── Connect ───────────────────────────────────────────────────────────────
554
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
555
472
  async connect() {
556
473
  if (this.logDebug) this.emit('debug', 'Connecting to MELCloud Home');
557
474
 
558
475
  try {
559
476
  const connectInfo = { State: false, Status: '', Account: {}, UseFahrenheit: false };
560
-
561
477
  const client = this.ensureAuthClient();
562
478
  const { verifier: codeVerifier, challenge: codeChallenge } = this.generatePkce();
563
479
  const state = crypto.randomBytes(16).toString('base64url');
564
480
 
565
- // ── Step 1: PAR ──────────────────────────────────────────────────
481
+ // ── Step 1: PAR ───────────────────────────────────────────────────
566
482
  if (this.logDebug) this.emit('debug', 'Step 1: PAR request');
567
483
 
568
484
  const parResp = await this.pace(() =>
@@ -609,18 +525,16 @@ class MelCloudHome extends EventEmitter {
609
525
 
610
526
  const finalUrl = authResp.request?.res?.responseUrl ?? authorizeUrl;
611
527
  const parsed = new URL(finalUrl);
612
- const body = typeof authResp.data === 'string'
613
- ? authResp.data
614
- : JSON.stringify(authResp.data);
528
+ const body = typeof authResp.data === 'string' ? authResp.data : JSON.stringify(authResp.data);
615
529
 
616
530
  if (parsed.hostname?.endsWith(ApiUrls.Home.CognitoDomainSuffix) && parsed.pathname.includes('/login')) {
617
- // Happy path: strona logowania Cognito
531
+ // Happy path: landed on the Cognito login page
618
532
  csrfToken = this.extractCsrfToken(body);
619
533
  if (!csrfToken) throw new Error('Failed to extract CSRF token from Cognito login page');
620
534
  cognitoLoginUrl = finalUrl;
621
535
  if (this.logDebug) this.emit('debug', 'Cognito login page OK');
622
536
  } else {
623
- // Fast path: istniejąca sesjakod dostępny od razu
537
+ // Fast path: existing IdentityServer session auth code available immediately
624
538
  const codeMatch = /code=([^&"' ]+)/.exec(finalUrl) || /code=([^&"' ]+)/.exec(body);
625
539
  if (codeMatch) {
626
540
  authCode = codeMatch[1];
@@ -636,20 +550,21 @@ class MelCloudHome extends EventEmitter {
636
550
  }
637
551
  }
638
552
 
639
- // Fast-path: pomiń etap logowania
553
+ // Skip credential submission when we already have a code
640
554
  if (authCode) {
641
555
  if (this.logDebug) this.emit('debug', 'Re-login with existing session (skipping credentials)');
642
556
  const exchangeRes = await this.exchangeCodeForTokens(client, authCode, codeVerifier);
643
557
  return await this.buildConnectInfo(connectInfo, exchangeRes);
644
558
  }
645
559
 
646
- // ── 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.
647
564
  if (this.logDebug) this.emit('debug', 'Step 3: Submit credentials to Cognito');
648
565
 
649
566
  const cognitoHostname = new URL(cognitoLoginUrl).hostname;
650
567
 
651
- // maxRedirects: 0 — Cognito używa response_mode=form_post.
652
- // Przechwytujemy 302 zanim axios podąży za nim do IdentityServera (→ 500).
653
568
  const credResp = await this.pace(() =>
654
569
  client.post(
655
570
  cognitoLoginUrl,
@@ -676,13 +591,14 @@ class MelCloudHome extends EventEmitter {
676
591
  this.emit('debug', `Step 3 response location: ${credResp.headers?.location ?? '(none)'}`);
677
592
  }
678
593
 
679
- // status 200 = zostaliśmy na stronie Cognitozłe hasło
594
+ // HTTP 200 means Cognito returned the login page again wrong password
680
595
  if (credResp.status === 200) throw new Error('Authentication failed: Invalid username or password');
681
596
  if (credResp.status >= 500) throw new Error(`Cognito server error: HTTP ${credResp.status}`);
682
597
 
683
- // ── Step 4: POST do signin-oidc-meu (emulacja form_post z Cognito) ──
684
- // Cognito normalnie robi POST z code+state w body do IdentityServera.
685
- // 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.
686
602
  if (this.logDebug) this.emit('debug', 'Step 4: Follow Cognito → IdentityServer redirect');
687
603
 
688
604
  const cognitoRedirectLocation = credResp.headers?.location ?? '';
@@ -711,9 +627,9 @@ class MelCloudHome extends EventEmitter {
711
627
  this.emit('debug', `Step 4 signin location: ${signinResp.headers?.location ?? '(none)'}`);
712
628
  }
713
629
 
714
- // ── Step 5: Podążaj za łańcuchem redirectów do auth code ──────────
715
- // IdentityServer przekierowuje przez kilka etapów:
716
- // /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://
717
633
  if (this.logDebug) this.emit('debug', 'Step 5: Following redirect chain to auth code');
718
634
 
719
635
  let currentResp = signinResp;
@@ -726,13 +642,13 @@ class MelCloudHome extends EventEmitter {
726
642
 
727
643
  if (this.logDebug) this.emit('debug', `Step 5 hop ${hop}: status=${hopStatus} location=${hopLocation || '(none)'}`);
728
644
 
729
- // A: melcloudhome:// z code=
645
+ // A: custom scheme redirect carrying the auth code
730
646
  if (hopLocation.startsWith('melcloudhome://')) {
731
647
  const m = /code=([^&"' ]+)/.exec(hopLocation);
732
648
  if (m) { authCode = m[1]; break; }
733
649
  }
734
650
 
735
- // B: /connect/authorize/callback w location lub body
651
+ // B: IdentityServer authorize callback delegate to helper
736
652
  const cbMatch = /\/connect\/authorize\/callback\?([^"' ]+)/.exec(hopLocation)
737
653
  || /\/connect\/authorize\/callback\?([^"' ]+)/.exec(hopBody);
738
654
  if (cbMatch) {
@@ -741,15 +657,15 @@ class MelCloudHome extends EventEmitter {
741
657
  break;
742
658
  }
743
659
 
744
- // C: code= bezpośrednio w location
660
+ // C: auth code directly in the Location header
745
661
  const codeInLocation = /code=([^&"' ]+)/.exec(hopLocation);
746
662
  if (codeInLocation) { authCode = codeInLocation[1]; break; }
747
663
 
748
- // D: code= w body
664
+ // D: auth code in the response body
749
665
  const codeInBody = /code=([^&"' ]+)/.exec(hopBody);
750
666
  if (codeInBody) { authCode = codeInBody[1]; break; }
751
667
 
752
- // Zwykły redirect — podążaj dalej
668
+ // Standard redirect — follow the next hop
753
669
  if ((hopStatus === 301 || hopStatus === 302 || hopStatus === 303) && hopLocation) {
754
670
  const nextUrl = hopLocation.startsWith('http')
755
671
  ? hopLocation
@@ -771,7 +687,7 @@ class MelCloudHome extends EventEmitter {
771
687
 
772
688
  if (this.logDebug) this.emit('debug', `Got auth code: ${authCode.slice(0, 20)}...`);
773
689
 
774
- // ── Step 6: Wymień kod na tokeny ──────────────────────────────────
690
+ // ── Step 6: Exchange auth code for tokens ─────────────────────────
775
691
  const exchangeRes = await this.exchangeCodeForTokens(client, authCode, codeVerifier);
776
692
  return await this.buildConnectInfo(connectInfo, exchangeRes);
777
693
 
@@ -779,6 +695,119 @@ class MelCloudHome extends EventEmitter {
779
695
  throw new Error(`Connect error: ${error.message}`);
780
696
  }
781
697
  }
698
+
699
+ // ── Scenes ────────────────────────────────────────────────────────────────
700
+
701
+ async checkScenesList() {
702
+ try {
703
+ if (this.logDebug) this.emit('debug', 'Scanning for scenes');
704
+
705
+ const resp = await this.client.get(ApiUrls.Home.Get.Scenes);
706
+ if (this.logDebug) this.emit('debug', `Scenes: ${JSON.stringify(resp.data, null, 2)}`);
707
+
708
+ return this.capitalizeKeysDeep(resp.data);
709
+ } catch (error) {
710
+ throw new Error(`Check scenes list error: ${error.message}`);
711
+ }
712
+ }
713
+
714
+ // ── Devices ───────────────────────────────────────────────────────────────
715
+
716
+ async checkDevicesList() {
717
+ try {
718
+ const result = { State: false, Status: null, Buildings: {}, Devices: [], Scenes: [] };
719
+ if (this.logDebug) this.emit('debug', 'Scanning for devices');
720
+
721
+ const resp = await this.client.get(ApiUrls.Home.Get.Context);
722
+ const userContext = resp.data;
723
+
724
+ const buildingsList = [
725
+ ...(userContext.buildings ?? []),
726
+ ...(userContext.guestBuildings ?? []),
727
+ ];
728
+
729
+ if (this.logDebug) this.emit('debug', `Buildings: ${JSON.stringify(buildingsList, null, 2)}`);
730
+
731
+ if (buildingsList.length === 0) {
732
+ result.Status = 'No buildings found';
733
+ return result;
734
+ }
735
+
736
+ // Shallow capitalize — used for flat objects (Capabilities, FrostProtection, etc.)
737
+ const capitalizeKeys = obj => Object.fromEntries(
738
+ Object.entries(obj).map(([k, v]) => [k.charAt(0).toUpperCase() + k.slice(1), v])
739
+ );
740
+
741
+ const createDevice = (device, type) => {
742
+ const settingsObject = Object.fromEntries(
743
+ (device.Settings || []).map(({ name, value }) => [
744
+ name.charAt(0).toUpperCase() + name.slice(1),
745
+ this.functions.convertValue(value),
746
+ ])
747
+ );
748
+
749
+ const deviceObject = {
750
+ ...capitalizeKeys(device.Capabilities || {}),
751
+ ...settingsObject,
752
+ DeviceType: type,
753
+ FirmwareAppVersion: device.ConnectedInterfaceIdentifier,
754
+ IsConnected: device.IsConnected,
755
+ };
756
+
757
+ if (device.FrostProtection) device.FrostProtection = capitalizeKeys(device.FrostProtection);
758
+ if (device.OverheatProtection) device.OverheatProtection = capitalizeKeys(device.OverheatProtection);
759
+ if (device.HolidayMode) device.HolidayMode = capitalizeKeys(device.HolidayMode);
760
+ if (Array.isArray(device.Schedule)) {
761
+ device.Schedule = device.Schedule.map(s => this.capitalizeKeysDeep(s));
762
+ }
763
+
764
+ const { Settings, Capabilities, Id, GivenDisplayName, ...rest } = device;
765
+
766
+ return {
767
+ ...rest,
768
+ Type: type,
769
+ DeviceID: Id,
770
+ DeviceName: GivenDisplayName,
771
+ SerialNumber: Id,
772
+ Device: deviceObject,
773
+ };
774
+ };
775
+
776
+ const devices = buildingsList.flatMap(building => [
777
+ ...(building.airToAirUnits || []).map(d => createDevice(capitalizeKeys(d), 0)),
778
+ ...(building.airToWaterUnits || []).map(d => createDevice(capitalizeKeys(d), 1)),
779
+ ...(building.airToVentilationUnits || []).map(d => createDevice(capitalizeKeys(d), 3)),
780
+ ]);
781
+
782
+ if (devices.length === 0) {
783
+ result.Status = 'No devices found';
784
+ return result;
785
+ }
786
+
787
+ let scenes = [];
788
+ try {
789
+ scenes = await this.checkScenesList();
790
+ if (this.logDebug) this.emit('debug', `Found ${scenes.length} scenes`);
791
+ } catch (error) {
792
+ if (this.logError) this.emit('error', `Get scenes error: ${error.message}`);
793
+ }
794
+
795
+ result.State = true;
796
+ result.Status = `Found ${devices.length} devices${scenes.length > 0 ? ` and ${scenes.length} scenes` : ''}`;
797
+ result.Buildings = userContext;
798
+ result.Devices = devices;
799
+ result.Scenes = scenes;
800
+
801
+ for (const deviceData of result.Devices) {
802
+ deviceData.Scenes = result.Scenes;
803
+ this.emit(deviceData.DeviceID, 'request', deviceData);
804
+ }
805
+
806
+ return result;
807
+ } catch (error) {
808
+ throw new Error(`Check devices list error: ${error.message}`);
809
+ }
810
+ }
782
811
  }
783
812
 
784
813
  export default MelCloudHome;