homebridge-melcloud-control 4.0.0-beta.43 → 4.0.0-beta.430

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/src/melcloud.js CHANGED
@@ -1,79 +1,87 @@
1
- import { Agent } from 'https';
2
1
  import axios from 'axios';
3
- import puppeteer from 'puppeteer';
4
2
  import EventEmitter from 'events';
3
+ import puppeteer from 'puppeteer-core';
4
+ import MelCloudHomeToken from './melcloudhometoken.js';
5
5
  import ImpulseGenerator from './impulsegenerator.js';
6
6
  import Functions from './functions.js';
7
7
  import { ApiUrls, ApiUrlsHome } from './constants.js';
8
8
 
9
9
  class MelCloud extends EventEmitter {
10
- constructor(displayType, user, passwd, language, accountFile, buildingsFile, devicesFile, logWarn, logDebug, requestConfig) {
10
+ constructor(account, accountFile, buildingsFile, devicesFile, pluginStart = false) {
11
11
  super();
12
- this.displayType = displayType;
13
- this.user = user;
14
- this.passwd = passwd;
15
- this.language = language;
12
+ this.accountType = account.type;
13
+ this.user = account.user;
14
+ this.passwd = account.passwd;
15
+ this.language = account.language;
16
+ this.logWarn = account.log?.warn;
17
+ this.logError = account.log?.error;
18
+ this.logDebug = account.log?.debug;
16
19
  this.accountFile = accountFile;
17
20
  this.buildingsFile = buildingsFile;
18
21
  this.devicesFile = devicesFile;
19
- this.logWarn = logWarn;
20
- this.logDebug = logDebug;
21
- this.requestConfig = requestConfig;
22
22
  this.devicesId = [];
23
23
  this.contextKey = '';
24
24
  this.functions = new Functions();
25
+ this.tokens = null;
25
26
 
26
- this.loginData = {
27
- Email: user,
28
- Password: passwd,
29
- Language: language,
30
- AppVersion: '1.34.12',
31
- CaptchaChallenge: '',
32
- CaptchaResponse: '',
33
- Persist: true
34
- };
35
-
36
- this.axiosDefaults = {
37
- timeout: 15000,
38
- maxContentLength: 100000000,
39
- maxBodyLength: 1000000000,
40
- httpsAgent: new Agent({
41
- keepAlive: false,
42
- rejectUnauthorized: false
43
- })
44
- };
45
-
46
- if (!requestConfig) {
27
+ if (pluginStart) {
28
+ //lock flags
29
+ this.locks = {
30
+ connect: false,
31
+ checkDevicesList: false
32
+ };
47
33
  this.impulseGenerator = new ImpulseGenerator()
48
- .on('checkDevicesList', async () => {
49
- try {
50
- await this.checkDevicesList(this.contextKey);
51
- } catch (error) {
52
- this.emit('error', `Impulse generator error: ${error}`);
53
- }
54
- })
34
+ .on('connect', () => this.handleWithLock('connect', async () => {
35
+ await this.connect(true);
36
+ }))
37
+ .on('checkDevicesList', () => this.handleWithLock('checkDevicesList', async () => {
38
+ await this.checkDevicesList();
39
+ }))
55
40
  .on('state', (state) => {
56
41
  this.emit('success', `Impulse generator ${state ? 'started' : 'stopped'}.`);
57
42
  });
58
43
  }
59
44
  }
60
45
 
61
- async checkMelcloudDevicesList(contextKey) {
46
+ async handleWithLock(lockKey, fn) {
47
+ if (this.locks[lockKey]) return;
48
+
49
+ this.locks[lockKey] = true;
50
+ try {
51
+ await fn();
52
+ } catch (error) {
53
+ this.emit('error', `Inpulse generator error: ${error}`);
54
+ } finally {
55
+ this.locks[lockKey] = false;
56
+ }
57
+ }
58
+
59
+ // MELCloud
60
+ async checkMelcloudDevicesList() {
62
61
  try {
63
- const axiosInstanceGet = axios.create({
62
+ const axiosInstance = axios.create({
64
63
  method: 'GET',
65
64
  baseURL: ApiUrls.BaseURL,
66
- headers: { 'X-MitsContextKey': contextKey },
67
- ...this.axiosDefaults
65
+ timeout: 15000,
66
+ headers: { 'X-MitsContextKey': this.contextKey }
68
67
  });
69
68
 
70
- if (this.logDebug) this.emit('debug', `Scanning for devices`);
71
- const listDevicesData = await axiosInstanceGet(ApiUrls.ListDevices);
69
+ if (this.logDebug) this.emit('debug', `Scanning for devices...`);
70
+
71
+ const listDevicesData = await axiosInstance(ApiUrls.ListDevices);
72
+
73
+ if (!listDevicesData || !listDevicesData.data) {
74
+ if (this.logWarn) this.emit('warn', `Invalid or empty response from MELCloud API`);
75
+ return null;
76
+ }
77
+
72
78
  const buildingsList = listDevicesData.data;
73
- if (this.logDebug) this.emit('debug', `Buildings: ${JSON.stringify(buildingsList, null, 2)}`);
74
79
 
75
- if (!buildingsList) {
76
- if (this.logWarn) this.emit('warn', `No building found`);
80
+ if (this.logDebug)
81
+ this.emit('debug', `Buildings: ${JSON.stringify(buildingsList, null, 2)}`);
82
+
83
+ if (!Array.isArray(buildingsList) || buildingsList.length === 0) {
84
+ if (this.logWarn) this.emit('warn', `No buildings found in MELCloud account`);
77
85
  return null;
78
86
  }
79
87
 
@@ -81,31 +89,55 @@ class MelCloud extends EventEmitter {
81
89
  if (this.logDebug) this.emit('debug', `Buildings list saved`);
82
90
 
83
91
  const devices = [];
92
+
84
93
  for (const building of buildingsList) {
85
- const buildingStructure = building.Structure;
94
+ if (!building.Structure) {
95
+ this.emit(
96
+ 'warn',
97
+ `Building missing structure: ${building.BuildingName || 'Unnamed'}`
98
+ );
99
+ continue;
100
+ }
101
+
102
+ const { Structure } = building;
103
+
86
104
  const allDevices = [
87
- ...buildingStructure.Floors.flatMap(floor => [
88
- ...floor.Areas.flatMap(area => area.Devices),
89
- ...floor.Devices
90
- ]),
91
- ...buildingStructure.Areas.flatMap(area => area.Devices),
92
- ...buildingStructure.Devices
93
- ];
105
+ ...(Structure.Floors?.flatMap(floor => [
106
+ ...(floor.Areas?.flatMap(area => area.Devices || []) || []),
107
+ ...(floor.Devices || [])
108
+ ]) || []),
109
+ ...(Structure.Areas?.flatMap(area => area.Devices || []) || []),
110
+ ...(Structure.Devices || [])
111
+ ].filter(d => d != null);
112
+
113
+ // Zamiana ID na string
114
+ allDevices.forEach(device => {
115
+ if (device.DeviceID != null) device.DeviceID = String(device.DeviceID);
116
+ });
117
+
118
+ if (this.logDebug) {
119
+ const count = allDevices.length;
120
+ this.emit(
121
+ 'debug',
122
+ `Found ${count} devices in building: ${building.BuildingName || 'Unnamed'}`
123
+ );
124
+ }
125
+
94
126
  devices.push(...allDevices);
95
127
  }
96
128
 
97
- const devicesCount = devices.length;
98
- if (devicesCount === 0) {
99
- if (this.logWarn) this.emit('warn', `No devices found`);
129
+ if (devices.length === 0) {
130
+ if (this.logWarn) this.emit('warn', `No devices found in any building`);
100
131
  return null;
101
132
  }
102
133
 
103
134
  await this.functions.saveData(this.devicesFile, devices);
104
- if (this.logDebug) this.emit('debug', `${devicesCount} devices saved`);
135
+ if (this.logDebug) this.emit('debug', `${devices.length} devices saved`);
105
136
 
106
137
  return devices;
107
138
  } catch (error) {
108
- throw new Error(`Check devices list error: ${error.message}`);
139
+ const msg = error.response ? `HTTP ${error.response.status}: ${error.response.statusText}` : error.message;
140
+ throw new Error(`Check devices list error: ${msg}`);
109
141
  }
110
142
  }
111
143
 
@@ -113,17 +145,26 @@ class MelCloud extends EventEmitter {
113
145
  if (this.logDebug) this.emit('debug', `Connecting to MELCloud`);
114
146
 
115
147
  try {
116
- const axiosInstanceLogin = axios.create({
148
+ const axiosInstance = axios.create({
117
149
  method: 'POST',
118
150
  baseURL: ApiUrls.BaseURL,
119
- ...this.axiosDefaults
151
+ timeout: 15000,
120
152
  });
121
153
 
122
- const accountData = await axiosInstanceLogin(ApiUrls.ClientLogin, { data: this.loginData });
154
+ const loginData = {
155
+ Email: this.user,
156
+ Password: this.passwd,
157
+ Language: this.language,
158
+ AppVersion: '1.34.12',
159
+ CaptchaChallenge: '',
160
+ CaptchaResponse: '',
161
+ Persist: true
162
+ };
163
+
164
+ const accountData = await axiosInstance(ApiUrls.ClientLogin, { data: loginData });
123
165
  const account = accountData.data;
124
- const accountInfo = account.LoginData;
125
- const contextKey = accountInfo?.ContextKey;
126
- const useFahrenheit = accountInfo?.UseFahrenheit ?? false;
166
+ const accountInfo = account.LoginData ?? [];
167
+ const contextKey = accountInfo.ContextKey;
127
168
  this.contextKey = contextKey;
128
169
 
129
170
  const debugData = {
@@ -142,35 +183,26 @@ class MelCloud extends EventEmitter {
142
183
  return null;
143
184
  }
144
185
 
145
- this.axiosInstancePost = axios.create({
146
- method: 'POST',
147
- baseURL: ApiUrls.BaseURL,
148
- headers: {
149
- 'X-MitsContextKey': contextKey,
150
- 'content-type': 'application/json'
151
- },
152
- ...this.axiosDefaults
153
- });
154
-
155
186
  await this.functions.saveData(this.accountFile, accountInfo);
156
-
157
187
  this.emit('success', `Connect to MELCloud Success`);
158
188
 
159
- return { accountInfo, contextKey, useFahrenheit };
189
+ return accountInfo
160
190
  } catch (error) {
161
- throw new Error(`Connect to MELCloud error: ${error.message}`);
191
+ throw new Error(`Connect error: ${error.message}`);
162
192
  }
163
193
  }
164
194
 
165
- async checkMelcloudHomeDevicesList(contextKey) {
195
+ // MELCloud Home
196
+ async checkMelcloudHomeDevicesList() {
166
197
  try {
167
198
  const axiosInstance = axios.create({
168
199
  method: 'GET',
169
200
  baseURL: ApiUrlsHome.BaseURL,
201
+ timeout: 25000,
170
202
  headers: {
171
203
  'Accept': '*/*',
172
204
  'Accept-Language': 'en-US,en;q=0.9',
173
- 'Cookie': contextKey,
205
+ 'Cookie': this.contextKey,
174
206
  'User-Agent': 'homebridge-melcloud-control/4.0.0',
175
207
  'DNT': '1',
176
208
  'Origin': 'https://melcloudhome.com',
@@ -179,8 +211,7 @@ class MelCloud extends EventEmitter {
179
211
  'Sec-Fetch-Mode': 'cors',
180
212
  'Sec-Fetch-Site': 'same-origin',
181
213
  'X-CSRF': '1'
182
- },
183
- ...this.axiosDefaults
214
+ }
184
215
  });
185
216
 
186
217
  if (this.logDebug) this.emit('debug', `Scanning for devices`);
@@ -196,10 +227,59 @@ class MelCloud extends EventEmitter {
196
227
  await this.functions.saveData(this.buildingsFile, buildingsList);
197
228
  if (this.logDebug) this.emit('debug', `Buildings list saved`);
198
229
 
199
- const devices = buildingsList.flatMap(building => [
200
- ...(building.airToAirUnits || []),
201
- ...(building.airToWaterUnits || [])
202
- ]);
230
+ const devices = buildingsList.flatMap(building => {
231
+ // Funkcja kapitalizująca klucze obiektu
232
+ const capitalizeKeys = obj =>
233
+ Object.fromEntries(
234
+ Object.entries(obj).map(([key, value]) => [
235
+ key.charAt(0).toUpperCase() + key.slice(1),
236
+ value
237
+ ])
238
+ );
239
+
240
+ // Funkcja tworząca finalny obiekt Device
241
+ const createDevice = (device, type) => {
242
+ // Settings już kapitalizowane w nazwach
243
+ const settingsArray = device.Settings || [];
244
+
245
+ const settingsObject = Object.fromEntries(
246
+ settingsArray.map(({ name, value }) => {
247
+ let parsedValue = value;
248
+ if (value === "True") parsedValue = true;
249
+ else if (value === "False") parsedValue = false;
250
+ else if (!isNaN(value) && value !== "") parsedValue = Number(value);
251
+
252
+ const key = name.charAt(0).toUpperCase() + name.slice(1);
253
+ return [key, parsedValue];
254
+ })
255
+ );
256
+
257
+ // Scal Capabilities + Settings + DeviceType w Device
258
+ const deviceObject = {
259
+ ...capitalizeKeys(device.Capabilities || {}),
260
+ ...settingsObject,
261
+ DeviceType: type
262
+ };
263
+
264
+ // Usuń stare pola Settings i Capabilities
265
+ const { Settings, Capabilities, Id, GivenDisplayName, ...rest } = device;
266
+
267
+ return {
268
+ ...rest,
269
+ ContextKey: this.contextKey,
270
+ Type: type,
271
+ DeviceID: Id,
272
+ DeviceName: GivenDisplayName,
273
+ Device: deviceObject
274
+ };
275
+ };
276
+
277
+ return [
278
+ ...(building.airToAirUnits || []).map(d => createDevice(capitalizeKeys(d), 0)),
279
+ ...(building.airToWaterUnits || []).map(d => createDevice(capitalizeKeys(d), 1)),
280
+ ...(building.airToVentilationUnits || []).map(d => createDevice(capitalizeKeys(d), 3))
281
+ ];
282
+ });
203
283
 
204
284
  const devicesCount = devices.length;
205
285
  if (devicesCount === 0) {
@@ -212,104 +292,205 @@ class MelCloud extends EventEmitter {
212
292
 
213
293
  return devices;
214
294
  } catch (error) {
215
- throw new Error(`Connect to MELCloud Home error: ${error.message}`);
295
+ throw new Error(`Check devices list error: ${error.message}`);
216
296
  }
217
297
  }
218
298
 
219
- async connectToMelCloudHome() {
220
- if (this.logDebug) this.emit('debug', `Connecting to MELCloud Home`);
299
+ async connectToMelCloudHome1() {
300
+ try {
301
+ const melCloudHomeToken = new MelCloudHomeToken({
302
+ user: this.user,
303
+ passwd: this.passwd,
304
+ logWarn: this.logWarn,
305
+ logError: this.logError,
306
+ logDebug: this.logDebug,
307
+ })
308
+ .on('success', message => this.emit('success', message))
309
+ .on('warn', warn => this.emit('warn', warn))
310
+ .on('error', error => this.emit('error', error));
221
311
 
222
- const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] });
223
- const page = await browser.newPage();
312
+ const { codeVerifier, url } = await melCloudHomeToken.buildAuthorizeUrl();
313
+ const code = await melCloudHomeToken.loginToMelCloudHome(url);
314
+ const token = await melCloudHomeToken.getTokens(code, codeVerifier);
315
+
316
+ const accountInfo = { ContextKey: code, UseFahrenheit: false };
317
+ this.contextKey = code;
318
+
319
+ return accountInfo
320
+ } catch (error) {
321
+ throw error;
322
+ }
323
+ }
324
+
325
+ async connectToMelCloudHome(refresh = false) {
326
+ if (this.logDebug) this.emit('debug', `Connecting to MELCloud Home`);
327
+ let browser;
224
328
 
225
329
  try {
226
- // Open MELCloud Home
227
- await page.goto(ApiUrlsHome.BaseURL, { waitUntil: 'networkidle2' });
330
+ // --- System Detection & Chromium install ---
331
+ const chromiumPath = await this.functions.ensureChromiumInstalled(
332
+ msg => this.emit('debug', msg),
333
+ msg => this.emit('warn', msg),
334
+ msg => this.emit('error', msg)
335
+ );
336
+
337
+ const launchOptions = {
338
+ headless: true,
339
+ executablePath: chromiumPath,
340
+ args: [
341
+ '--no-sandbox',
342
+ '--disable-setuid-sandbox',
343
+ '--disable-dev-shm-usage',
344
+ '--single-process',
345
+ '--no-zygote'
346
+ ]
347
+ };
348
+
349
+ browser = await puppeteer.launch(launchOptions);
350
+ const page = await browser.newPage();
351
+
352
+ // --- Event handlers ---
353
+ page.on('error', err => this.emit('error', `Page crashed: ${err.message}`));
354
+ page.on('pageerror', err => this.emit('error', `Browser error: ${err.message}`));
355
+ page.on('close', () => this.emit('debug', 'Page was closed unexpectedly'));
356
+ browser.on('disconnected', () => this.emit('debug', 'Browser disconnected unexpectedly'));
357
+
358
+ page.setDefaultTimeout(30000);
359
+ page.setDefaultNavigationTimeout(30000);
360
+
361
+ // --- Go to login page ---
362
+ await page.goto(ApiUrlsHome.BaseURL, { waitUntil: ['domcontentloaded', 'networkidle2'] });
363
+
364
+ // --- Login flow ---
228
365
  const buttons = await page.$$('button.btn--blue');
229
366
  let loginBtn = null;
230
367
  for (const btn of buttons) {
231
- const text = await page.evaluate(el => el.textContent, btn);
232
- if (text.trim() === 'Zaloguj' || text.trim() === 'Log In') {
368
+ const text = await page.evaluate(el => el.textContent.trim(), btn);
369
+ if (['Zaloguj', 'Sign In', 'Login'].includes(text)) {
233
370
  loginBtn = btn;
234
371
  break;
235
372
  }
236
373
  }
374
+ if (!loginBtn) {
375
+ this.emit('warn', 'Login button not found');
376
+ return null;
377
+ }
237
378
 
238
- if (!loginBtn && this.logWarn) this.emit('warn', `Login button not found`);
379
+ await Promise.race([
380
+ Promise.all([
381
+ loginBtn.click(),
382
+ page.waitForNavigation({ waitUntil: ['domcontentloaded', 'networkidle2'], timeout: 15000 })
383
+ ]),
384
+ new Promise(r => setTimeout(r, 12000))
385
+ ]);
386
+
387
+ // --- Credentials ---
388
+ const usernameInput = await page.$('input[name="username"]');
389
+ const passwordInput = await page.$('input[name="password"]');
390
+ if (!usernameInput || !passwordInput) {
391
+ this.emit('warn', 'Username or password input not found');
392
+ return null;
393
+ }
239
394
 
240
- // Set credentials and login
241
- await Promise.all([loginBtn.click(), page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 20000 })]);
242
- await page.waitForSelector('input[name="username"]', { timeout: 15000 });
243
395
  await page.type('input[name="username"]', this.user, { delay: 50 });
244
396
  await page.type('input[name="password"]', this.passwd, { delay: 50 });
245
397
 
246
- const button1 = await page.$('input[type="submit"]');
247
- await Promise.all([button1.click(), page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 20000 })]);
398
+ const submitButton = await page.$('input[type="submit"], button[type="submit"]');
399
+ if (!submitButton) {
400
+ this.emit('warn', 'Submit button not found on login form');
401
+ return null;
402
+ }
248
403
 
249
- // Get cookies C1 and C2
404
+ await Promise.race([
405
+ Promise.all([
406
+ submitButton.click(),
407
+ page.waitForNavigation({ waitUntil: ['domcontentloaded', 'networkidle2'], timeout: 20000 })
408
+ ]),
409
+ new Promise(r => setTimeout(r, 15000))
410
+ ]);
411
+
412
+ // --- Cookie extraction ---
250
413
  let c1 = null, c2 = null;
251
414
  const start = Date.now();
252
-
253
- // Loop max 20s
254
415
  while ((!c1 || !c2) && Date.now() - start < 20000) {
255
- const cookies = await page.cookies();
416
+ const cookies = await page.browserContext().cookies();
256
417
  c1 = cookies.find(c => c.name === '__Secure-monitorandcontrolC1')?.value || c1;
257
418
  c2 = cookies.find(c => c.name === '__Secure-monitorandcontrolC2')?.value || c2;
258
419
  if (!c1 || !c2) await new Promise(r => setTimeout(r, 500));
259
420
  }
260
421
 
261
422
  if (!c1 || !c2) {
262
- if (this.logWarn) this.emit('warn', `Cookies C1/C2 missing`);
423
+ this.emit('warn', 'Cookies C1/C2 missing after login');
263
424
  return null;
264
425
  }
265
426
 
266
- const accountInfo = {};
267
- const contextKey = ['__Secure-monitorandcontrol=chunks-2', `__Secure-monitorandcontrolC1=${c1}`, `__Secure-monitorandcontrolC2=${c2}`,].join('; ');
268
- const useFahrenheit = false;
427
+ const contextKey = [
428
+ '__Secure-monitorandcontrol=chunks-2',
429
+ `__Secure-monitorandcontrolC1=${c1}`,
430
+ `__Secure-monitorandcontrolC2=${c2}`
431
+ ].join('; ');
432
+
433
+ const accountInfo = { ContextKey: contextKey, UseFahrenheit: false };
269
434
  this.contextKey = contextKey;
435
+ await this.functions.saveData(this.accountFile, accountInfo);
270
436
 
271
- this.emit('success', `Connect to MELCloud Home Success`);
437
+ if (!refresh) this.emit('success', `Connect to MELCloud Home Success`);
438
+ return accountInfo;
272
439
 
273
- return { accountInfo, contextKey, useFahrenheit };
274
440
  } catch (error) {
275
- throw new Error(`Connect to MELCloud Home error: ${error.message}`);
441
+ throw new Error(`Connect error: ${error.message}`);
276
442
  } finally {
277
- await browser.close();
443
+ if (browser) {
444
+ try {
445
+ await browser.close();
446
+ } catch (closeErr) {
447
+ this.emit('error', `Failed to close Puppeteer browser: ${closeErr.message}`);
448
+ }
449
+ }
278
450
  }
279
451
  }
280
452
 
281
- async connect() {
282
- let response = {};
283
- switch (this.displayType) {
453
+ async checkDevicesList() {
454
+ let devices = [];
455
+ switch (this.accountType) {
284
456
  case "melcloud":
285
- response = await this.connectToMelCloud();
286
- return response
457
+ devices = await this.checkMelcloudDevicesList();
458
+ return devices
287
459
  case "melcloudhome":
288
- response = await this.connectToMelCloudHome();
289
- return response
460
+ devices = await this.checkMelcloudHomeDevicesList();
461
+ return devices;
290
462
  default:
291
- return response
463
+ return devices;
292
464
  }
293
465
  }
294
466
 
295
- async checkDevicesList(contextKey) {
296
- let devices = [];
297
- switch (this.displayType) {
467
+ async connect(refresh) {
468
+ let response = {};
469
+ switch (this.accountType) {
298
470
  case "melcloud":
299
- devices = await this.checkMelcloudDevicesList(contextKey);
300
- return devices
471
+ response = await this.connectToMelCloud();
472
+ return response
301
473
  case "melcloudhome":
302
- devices = await this.checkMelcloudHomeDevicesList(contextKey);
303
- return devices
474
+ response = await this.connectToMelCloudHome(refresh);
475
+ return response
304
476
  default:
305
- return devices;
477
+ return response
306
478
  }
307
479
  }
308
480
 
309
481
  async send(accountInfo) {
310
482
  try {
483
+ const axiosInstance = axios.create({
484
+ baseURL: ApiUrls.BaseURL,
485
+ timeout: 15000,
486
+ headers: {
487
+ 'X-MitsContextKey': this.contextKey,
488
+ 'content-type': 'application/json'
489
+ }
490
+ });
491
+
311
492
  const options = { data: accountInfo };
312
- await this.axiosInstancePost(ApiUrls.UpdateApplicationOptions, options);
493
+ await axiosInstance.post(ApiUrls.UpdateApplicationOptions, options);
313
494
  await this.functions.saveData(this.accountFile, accountInfo);
314
495
  return true;
315
496
  } catch (error) {