homebridge-melcloud-control 4.4.1-beta.21 → 4.4.1-beta.23
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 +1 -0
- package/package.json +1 -1
- package/src/functions.js +7 -6
- package/src/melcloudhome.js +200 -2
package/CHANGELOG.md
CHANGED
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.
|
|
4
|
+
"version": "4.4.1-beta.23",
|
|
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/functions.js
CHANGED
|
@@ -78,12 +78,13 @@ class Functions extends EventEmitter {
|
|
|
78
78
|
await access('/.dockerenv');
|
|
79
79
|
isDocker = true;
|
|
80
80
|
} catch { }
|
|
81
|
+
|
|
81
82
|
try {
|
|
82
|
-
const { stdout } = await execPromise('cat /proc/1/cgroup
|
|
83
|
+
const { stdout } = await execPromise('cat /proc/1/cgroup');
|
|
83
84
|
if (stdout.includes('docker') || stdout.includes('containerd')) isDocker = true;
|
|
84
85
|
} catch { }
|
|
85
86
|
|
|
86
|
-
const result = { path: null, arch, system:
|
|
87
|
+
const result = { path: null, arch, system: 'unknown' };
|
|
87
88
|
|
|
88
89
|
/* ===================== macOS ===================== */
|
|
89
90
|
if (isMac) {
|
|
@@ -127,7 +128,7 @@ class Functions extends EventEmitter {
|
|
|
127
128
|
try {
|
|
128
129
|
await access(path, fs.constants.X_OK);
|
|
129
130
|
result.path = path;
|
|
130
|
-
result.system = '
|
|
131
|
+
result.system = 'Qnap';
|
|
131
132
|
return result;
|
|
132
133
|
} catch { }
|
|
133
134
|
}
|
|
@@ -202,9 +203,9 @@ class Functions extends EventEmitter {
|
|
|
202
203
|
}
|
|
203
204
|
|
|
204
205
|
return result;
|
|
205
|
-
} catch (
|
|
206
|
-
if (this.logError) this.emit('error', `Chromium detection error: ${
|
|
207
|
-
return { path: null, arch: 'unknown' };
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (this.logError) this.emit('error', `Chromium detection error: ${error.message}`);
|
|
208
|
+
return { path: null, arch: 'unknown', system: 'unknown' };
|
|
208
209
|
}
|
|
209
210
|
}
|
|
210
211
|
|
package/src/melcloudhome.js
CHANGED
|
@@ -9,6 +9,9 @@ import Functions from './functions.js';
|
|
|
9
9
|
import { ApiUrls, LanguageLocaleMap } from './constants.js';
|
|
10
10
|
const execPromise = promisify(exec);
|
|
11
11
|
|
|
12
|
+
const crypto = require('crypto');
|
|
13
|
+
const { URL } = require('url');
|
|
14
|
+
|
|
12
15
|
class MelCloudHome extends EventEmitter {
|
|
13
16
|
constructor(account, accountFile, buildingsFile, pluginStart = false) {
|
|
14
17
|
super();
|
|
@@ -28,6 +31,8 @@ class MelCloudHome extends EventEmitter {
|
|
|
28
31
|
this.socketConnected = false;
|
|
29
32
|
this.heartbeat = null;
|
|
30
33
|
|
|
34
|
+
this.cookies = [];
|
|
35
|
+
|
|
31
36
|
this.functions = new Functions(this.logWarn, this.logError, this.logDebug)
|
|
32
37
|
.on('warn', warn => this.emit('warn', warn))
|
|
33
38
|
.on('error', error => this.emit('error', error))
|
|
@@ -215,7 +220,7 @@ class MelCloudHome extends EventEmitter {
|
|
|
215
220
|
}
|
|
216
221
|
}
|
|
217
222
|
|
|
218
|
-
async
|
|
223
|
+
async connect1() {
|
|
219
224
|
if (this.logDebug) this.emit('debug', 'Connecting to MELCloud Home');
|
|
220
225
|
const GLOBAL_TIMEOUT = 120000;
|
|
221
226
|
|
|
@@ -421,7 +426,7 @@ class MelCloudHome extends EventEmitter {
|
|
|
421
426
|
baseURL: ApiUrls.Home.Base,
|
|
422
427
|
timeout: 30000,
|
|
423
428
|
headers: headers
|
|
424
|
-
})
|
|
429
|
+
});
|
|
425
430
|
this.emit('client', this.client);
|
|
426
431
|
|
|
427
432
|
accountInfo.State = true;
|
|
@@ -440,6 +445,199 @@ class MelCloudHome extends EventEmitter {
|
|
|
440
445
|
}
|
|
441
446
|
}
|
|
442
447
|
}
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
async axiosRequest(url, options, jar, maxRedirects = 10) {
|
|
451
|
+
let currentUrl = url;
|
|
452
|
+
|
|
453
|
+
for (let i = 0; i < maxRedirects; i++) {
|
|
454
|
+
const response = await axios({
|
|
455
|
+
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,
|
|
463
|
+
maxRedirects: 0,
|
|
464
|
+
responseType: 'text',
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
jar.addFromHeader(response.headers['set-cookie'], currentUrl);
|
|
468
|
+
|
|
469
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
470
|
+
const location = response.headers.location;
|
|
471
|
+
if (!location) break;
|
|
472
|
+
|
|
473
|
+
if (location.startsWith('melcloudhome://')) {
|
|
474
|
+
return { url: location, data: response.data };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
currentUrl = location.startsWith('http')
|
|
478
|
+
? location
|
|
479
|
+
: new URL(location, currentUrl).toString();
|
|
480
|
+
|
|
481
|
+
options.method = 'GET';
|
|
482
|
+
options.body = undefined;
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return { url: currentUrl, data: response.data };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
throw new Error('Too many redirects');
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async loginWithCredentials(email, password) {
|
|
493
|
+
const jar = new CookieJar();
|
|
494
|
+
|
|
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';
|
|
497
|
+
|
|
498
|
+
const codeVerifier = crypto.randomBytes(32).toString('base64url');
|
|
499
|
+
const codeChallenge = crypto
|
|
500
|
+
.createHash('sha256')
|
|
501
|
+
.update(codeVerifier)
|
|
502
|
+
.digest('base64url');
|
|
503
|
+
|
|
504
|
+
const authUrl =
|
|
505
|
+
'https://auth.melcloudhome.com/connect/authorize?' +
|
|
506
|
+
new URLSearchParams({
|
|
507
|
+
client_id: 'homemobile',
|
|
508
|
+
redirect_uri: 'melcloudhome://',
|
|
509
|
+
response_type: 'code',
|
|
510
|
+
scope: 'openid profile email offline_access IdentityServerApi',
|
|
511
|
+
code_challenge: codeChallenge,
|
|
512
|
+
code_challenge_method: 'S256',
|
|
513
|
+
}).toString();
|
|
514
|
+
|
|
515
|
+
// STEP 1 – get login page
|
|
516
|
+
const loginPage = await axiosRequest(authUrl, {
|
|
517
|
+
headers: { 'User-Agent': userAgent },
|
|
518
|
+
}, jar);
|
|
519
|
+
|
|
520
|
+
const csrfMatch =
|
|
521
|
+
loginPage.data.match(/name="(_csrf|__RequestVerificationToken)".*?value="([^"]+)"/);
|
|
522
|
+
|
|
523
|
+
if (!csrfMatch) throw new Error('CSRF token not found');
|
|
524
|
+
const csrfToken = csrfMatch[2];
|
|
525
|
+
|
|
526
|
+
// STEP 2 – submit credentials
|
|
527
|
+
const form = new URLSearchParams({
|
|
528
|
+
_csrf: csrfToken,
|
|
529
|
+
username: email,
|
|
530
|
+
password,
|
|
531
|
+
}).toString();
|
|
532
|
+
|
|
533
|
+
const loginResult = await axiosRequest(loginPage.url, {
|
|
534
|
+
method: 'POST',
|
|
535
|
+
headers: {
|
|
536
|
+
'User-Agent': userAgent,
|
|
537
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
538
|
+
'Content-Length': Buffer.byteLength(form),
|
|
539
|
+
},
|
|
540
|
+
body: form,
|
|
541
|
+
}, jar);
|
|
542
|
+
|
|
543
|
+
if (!loginResult.url.startsWith('melcloudhome://')) {
|
|
544
|
+
throw new Error('OAuth redirect not received');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const code = new URL(loginResult.url).searchParams.get('code');
|
|
548
|
+
if (!code) throw new Error('Authorization code missing');
|
|
549
|
+
|
|
550
|
+
// STEP 3 – exchange token
|
|
551
|
+
const tokenResponse = await axios.post(
|
|
552
|
+
'https://auth.melcloudhome.com/connect/token',
|
|
553
|
+
new URLSearchParams({
|
|
554
|
+
grant_type: 'authorization_code',
|
|
555
|
+
client_id: 'homemobile',
|
|
556
|
+
redirect_uri: 'melcloudhome://',
|
|
557
|
+
code,
|
|
558
|
+
code_verifier: codeVerifier,
|
|
559
|
+
}).toString(),
|
|
560
|
+
{
|
|
561
|
+
headers: {
|
|
562
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
563
|
+
'Authorization': 'Basic aG9tZW1vYmlsZTo=',
|
|
564
|
+
},
|
|
565
|
+
},
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
if (!tokenResponse.data.refresh_token) {
|
|
569
|
+
throw new Error('No refresh token returned');
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
refreshToken: tokenResponse.data.refresh_token,
|
|
574
|
+
accessToken: tokenResponse.data.access_token,
|
|
575
|
+
expiresIn: tokenResponse.data.expires_in,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
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
|
+
async connect() {
|
|
628
|
+
if (this.logDebug) this.emit('debug', 'Connecting to MELCloud Home');
|
|
629
|
+
|
|
630
|
+
try {
|
|
631
|
+
const accountInfo = { State: false, Info: '', Account: {}, UseFahrenheit: false };
|
|
632
|
+
|
|
633
|
+
const tokens = await loginWithCredentials(this.user, this.passwd);
|
|
634
|
+
this.emit('debug', `Tokens: ${JSON.stringify(tokens, null, 2)}`);
|
|
635
|
+
return accountInfo
|
|
636
|
+
} catch (error) {
|
|
637
|
+
throw new Error(`Connect error: ${error.message}`);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
443
641
|
}
|
|
444
642
|
|
|
445
643
|
export default MelCloudHome;
|