homebridge-melcloud-control 4.4.1-beta.25 → 4.4.1-beta.27

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "displayName": "MELCloud Control",
3
3
  "name": "homebridge-melcloud-control",
4
- "version": "4.4.1-beta.25",
4
+ "version": "4.4.1-beta.27",
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/helper.js ADDED
@@ -0,0 +1,70 @@
1
+ class CookieJar {
2
+ constructor() {
3
+ this.cookies = [];
4
+ }
5
+
6
+ addFromHeader(setCookie, url) {
7
+ if (!setCookie) return;
8
+
9
+ const urlObj = new URL(url);
10
+ const headers = Array.isArray(setCookie) ? setCookie : [setCookie];
11
+
12
+ for (const header of headers) {
13
+ const parts = header.split(';').map(p => p.trim());
14
+ const [name, value] = parts[0].split('=');
15
+
16
+ const cookie = {
17
+ name,
18
+ value,
19
+ domain: urlObj.hostname,
20
+ path: '/',
21
+ expires: null,
22
+ };
23
+
24
+ for (const part of parts.slice(1)) {
25
+ const [key, val] = part.split('=').map(v => v?.trim());
26
+ if (!key) continue;
27
+
28
+ switch (key.toLowerCase()) {
29
+ case 'domain':
30
+ cookie.domain = val;
31
+ break;
32
+ case 'path':
33
+ cookie.path = val || '/';
34
+ break;
35
+ case 'expires':
36
+ cookie.expires = new Date(val);
37
+ break;
38
+ case 'max-age':
39
+ cookie.expires = new Date(Date.now() + Number(val) * 1000);
40
+ break;
41
+ }
42
+ }
43
+
44
+ this.cookies = this.cookies.filter(
45
+ c => !(c.name === cookie.name && c.domain === cookie.domain && c.path === cookie.path),
46
+ );
47
+
48
+ this.cookies.push(cookie);
49
+ }
50
+ }
51
+
52
+ headerFor(url) {
53
+ const urlObj = new URL(url);
54
+ const now = Date.now();
55
+
56
+ const validCookies = this.cookies.filter(cookie => {
57
+ if (cookie.expires && cookie.expires.getTime() <= now) return false;
58
+ if (!urlObj.hostname.endsWith(cookie.domain)) return false;
59
+ if (!urlObj.pathname.startsWith(cookie.path)) return false;
60
+ return true;
61
+ });
62
+
63
+ return validCookies.length
64
+ ? validCookies.map(c => `${c.name}=${c.value}`).join('; ')
65
+ : null;
66
+ }
67
+ }
68
+
69
+
70
+ export default CookieJar
@@ -9,6 +9,8 @@ import Functions from './functions.js';
9
9
  import { ApiUrls, LanguageLocaleMap } from './constants.js';
10
10
  const execPromise = promisify(exec);
11
11
 
12
+ import CookieJar from './helper.js';
13
+
12
14
  import crypto from 'crypto';
13
15
  import { URL } from 'url';
14
16
 
@@ -31,8 +33,6 @@ class MelCloudHome extends EventEmitter {
31
33
  this.socketConnected = false;
32
34
  this.heartbeat = null;
33
35
 
34
- this.cookies = [];
35
-
36
36
  this.functions = new Functions(this.logWarn, this.logError, this.logDebug)
37
37
  .on('warn', warn => this.emit('warn', warn))
38
38
  .on('error', error => this.emit('error', error))
@@ -447,53 +447,93 @@ class MelCloudHome extends EventEmitter {
447
447
  }
448
448
 
449
449
 
450
- async axiosRequest(url, options, jar, maxRedirects = 10) {
450
+ async axiosRequest(url, options, jar, maxRedirects = 15) {
451
451
  let currentUrl = url;
452
+ let previousUrl = null;
453
+ let method = options.method || 'GET';
454
+ let body = options.body;
452
455
 
453
456
  for (let i = 0; i < maxRedirects; i++) {
457
+ const cookieHeader = jar.headerFor(currentUrl);
458
+
459
+ const headers = {
460
+ 'User-Agent': options.headers?.['User-Agent'],
461
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
462
+ 'Accept-Language': 'en-US,en;q=0.9',
463
+ 'Accept-Encoding': 'identity',
464
+ ...(previousUrl ? { Referer: previousUrl } : {}),
465
+ ...(cookieHeader ? { Cookie: cookieHeader } : {}),
466
+ ...options.headers,
467
+ };
468
+
454
469
  const response = await axios({
455
470
  url: currentUrl,
456
- method: options.method || 'GET',
457
- data: options.body,
458
- headers: {
459
- ...options.headers,
460
- ...(jar.headerFor(currentUrl) && { Cookie: jar.headerFor(currentUrl) }),
461
- },
462
- validateStatus: () => true,
471
+ method,
472
+ data: body,
473
+ headers,
463
474
  maxRedirects: 0,
475
+ validateStatus: () => true,
464
476
  responseType: 'text',
465
477
  });
466
478
 
467
479
  jar.addFromHeader(response.headers['set-cookie'], currentUrl);
468
480
 
481
+ /* ========= HTTP REDIRECT ========= */
469
482
  if ([301, 302, 303, 307, 308].includes(response.status)) {
470
483
  const location = response.headers.location;
471
484
  if (!location) break;
472
485
 
486
+ // SUCCESS: app redirect
473
487
  if (location.startsWith('melcloudhome://')) {
474
488
  return { url: location, data: response.data };
475
489
  }
476
490
 
491
+ previousUrl = currentUrl;
477
492
  currentUrl = location.startsWith('http')
478
493
  ? location
479
494
  : new URL(location, currentUrl).toString();
480
495
 
481
- options.method = 'GET';
482
- options.body = undefined;
496
+ method = 'GET';
497
+ body = undefined;
483
498
  continue;
484
499
  }
485
500
 
486
- return { url: currentUrl, data: response.data };
501
+ /* ========= META REFRESH ========= */
502
+ const metaMatch = response.data?.match(
503
+ /<meta[^>]+http-equiv=["']?refresh["']?[^>]+content=["']?\d+;\s*url=([^"'>]+)["']?/i
504
+ );
505
+
506
+ if (metaMatch) {
507
+ const redirectPath = metaMatch[1]
508
+ .replace(/&amp;/g, '&')
509
+ .replace(/&#x2F;/g, '/');
510
+
511
+ previousUrl = currentUrl;
512
+ currentUrl = redirectPath.startsWith('http')
513
+ ? redirectPath
514
+ : new URL(redirectPath, currentUrl).toString();
515
+
516
+ method = 'GET';
517
+ body = undefined;
518
+ continue;
519
+ }
520
+
521
+ /* ========= FINAL RESPONSE ========= */
522
+ return {
523
+ url: currentUrl,
524
+ data: response.data,
525
+ headers: response.headers,
526
+ };
487
527
  }
488
528
 
489
- throw new Error('Too many redirects');
529
+ throw new Error('OAuth flow aborted: too many redirects');
490
530
  }
491
531
 
532
+
492
533
  async loginWithCredentials(email, password) {
493
534
  const jar = new CookieJar();
494
535
 
495
- const userAgent =
496
- 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile Safari/604.1';
536
+ const userAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile Safari/604.1';
497
537
 
498
538
  const codeVerifier = crypto.randomBytes(32).toString('base64url');
499
539
  const codeChallenge = crypto
@@ -501,8 +541,7 @@ class MelCloudHome extends EventEmitter {
501
541
  .update(codeVerifier)
502
542
  .digest('base64url');
503
543
 
504
- const authUrl =
505
- 'https://auth.melcloudhome.com/connect/authorize?' +
544
+ const authUrl = 'https://auth.melcloudhome.com/connect/authorize?' +
506
545
  new URLSearchParams({
507
546
  client_id: 'homemobile',
508
547
  redirect_uri: 'melcloudhome://',
@@ -517,9 +556,7 @@ class MelCloudHome extends EventEmitter {
517
556
  headers: { 'User-Agent': userAgent },
518
557
  }, jar);
519
558
 
520
- const csrfMatch =
521
- loginPage.data.match(/name="(_csrf|__RequestVerificationToken)".*?value="([^"]+)"/);
522
-
559
+ const csrfMatch = loginPage.data.match(/name="(_csrf|__RequestVerificationToken)".*?value="([^"]+)"/);
523
560
  if (!csrfMatch) throw new Error('CSRF token not found');
524
561
  const csrfToken = csrfMatch[2];
525
562
 
@@ -548,8 +585,7 @@ class MelCloudHome extends EventEmitter {
548
585
  if (!code) throw new Error('Authorization code missing');
549
586
 
550
587
  // STEP 3 – exchange token
551
- const tokenResponse = await axios.post(
552
- 'https://auth.melcloudhome.com/connect/token',
588
+ const tokenResponse = await axios.post('https://auth.melcloudhome.com/connect/token',
553
589
  new URLSearchParams({
554
590
  grant_type: 'authorization_code',
555
591
  client_id: 'homemobile',
@@ -576,54 +612,6 @@ class MelCloudHome extends EventEmitter {
576
612
  };
577
613
  }
578
614
 
579
- addFromHeader(setCookie, url) {
580
- if (!setCookie) return;
581
- const urlObj = new URL(url);
582
- const cookies = Array.isArray(setCookie) ? setCookie : [setCookie];
583
-
584
- for (const header of cookies) {
585
- const parts = header.split(';').map(p => p.trim());
586
- const [name, value] = parts[0].split('=');
587
-
588
- const cookie = {
589
- name,
590
- value,
591
- domain: urlObj.hostname,
592
- path: '/',
593
- expires: null,
594
- };
595
-
596
- for (const part of parts.slice(1)) {
597
- const [k, v] = part.split('=');
598
- if (!k) continue;
599
- const key = k.toLowerCase();
600
- if (key === 'domain') cookie.domain = v;
601
- if (key === 'path') cookie.path = v;
602
- if (key === 'expires') cookie.expires = new Date(v);
603
- if (key === 'max-age') cookie.expires = new Date(Date.now() + Number(v) * 1000);
604
- }
605
-
606
- this.cookies = this.cookies.filter(
607
- c => !(c.name === cookie.name && c.domain === cookie.domain && c.path === cookie.path),
608
- );
609
- this.cookies.push(cookie);
610
- }
611
- }
612
-
613
- headerFor(url) {
614
- const urlObj = new URL(url);
615
- const now = Date.now();
616
-
617
- return this.cookies
618
- .filter(c =>
619
- (!c.expires || c.expires.getTime() > now) &&
620
- urlObj.hostname.endsWith(c.domain) &&
621
- urlObj.pathname.startsWith(c.path),
622
- )
623
- .map(c => `${c.name}=${c.value}`)
624
- .join('; ');
625
- }
626
-
627
615
  async connect() {
628
616
  if (this.logDebug) this.emit('debug', 'Connecting to MELCloud Home');
629
617