homebridge-melcloud-control 4.4.1-beta.27 → 4.4.1-beta.29
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 +2 -3
- package/src/functions.js +11 -11
- package/src/melcloudhome.js +1 -187
- package/src/helper.js +0 -70
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.29",
|
|
5
5
|
"description": "Homebridge plugin to control Mitsubishi Air Conditioner, Heat Pump and Energy Recovery Ventilation.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "grzegorz914",
|
|
@@ -40,8 +40,7 @@
|
|
|
40
40
|
"axios": "^1.13.2",
|
|
41
41
|
"express": "^5.2.1",
|
|
42
42
|
"puppeteer": "^24.33.0",
|
|
43
|
-
"ws": "^8.18.3"
|
|
44
|
-
"crypto": "^1.0.1"
|
|
43
|
+
"ws": "^8.18.3"
|
|
45
44
|
},
|
|
46
45
|
"keywords": [
|
|
47
46
|
"homebridge",
|
package/src/functions.js
CHANGED
|
@@ -88,10 +88,7 @@ class Functions extends EventEmitter {
|
|
|
88
88
|
|
|
89
89
|
/* ===================== macOS ===================== */
|
|
90
90
|
if (isMac) {
|
|
91
|
-
const macCandidates = [
|
|
92
|
-
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
93
|
-
'/Applications/Chromium.app/Contents/MacOS/Chromium'
|
|
94
|
-
];
|
|
91
|
+
const macCandidates = ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium'];
|
|
95
92
|
for (const path of macCandidates) {
|
|
96
93
|
try {
|
|
97
94
|
await access(path, fs.constants.X_OK);
|
|
@@ -138,7 +135,6 @@ class Functions extends EventEmitter {
|
|
|
138
135
|
/* ===================== Linux ARM ===================== */
|
|
139
136
|
if (isLinux && isARM) {
|
|
140
137
|
const armCandidates = ['/usr/bin/chromium-browser', '/usr/bin/chromium', '/snap/bin/chromium'];
|
|
141
|
-
|
|
142
138
|
for (const path of armCandidates) {
|
|
143
139
|
try {
|
|
144
140
|
await access(path, fs.constants.X_OK);
|
|
@@ -151,8 +147,7 @@ class Functions extends EventEmitter {
|
|
|
151
147
|
if (!isDocker) {
|
|
152
148
|
try {
|
|
153
149
|
await execPromise('sudo apt update -y');
|
|
154
|
-
await execPromise('sudo apt install -y chromium chromium-browser chromium-codecs-ffmpeg');
|
|
155
|
-
await execPromise('sudo apt install -y libnspr4 libnss3 libx11-6 libxcomposite1 libxdamage1 libxrandr2 libatk1.0-0 libcups2 libdrm2 libgbm1 libasound2');
|
|
150
|
+
await execPromise('sudo apt install -y chromium chromium-browser chromium-codecs-ffmpeg libnspr4 libnss3 libx11-6 libxcomposite1 libxdamage1 libxrandr2 libatk1.0-0 libcups2 libdrm2 libgbm1 libasound2');
|
|
156
151
|
} catch (error) {
|
|
157
152
|
if (this.logError) this.emit('error', `Install package for Linux ARM error: ${error}`);
|
|
158
153
|
}
|
|
@@ -192,13 +187,18 @@ class Functions extends EventEmitter {
|
|
|
192
187
|
if (isDocker) {
|
|
193
188
|
try {
|
|
194
189
|
await execPromise('apt update -y && apt install -y chromium');
|
|
195
|
-
await access('/usr/bin/chromium', fs.constants.X_OK);
|
|
196
|
-
result.path = '/usr/bin/chromium';
|
|
197
|
-
result.system = 'Linux Docker';
|
|
198
|
-
return result;
|
|
199
190
|
} catch (error) {
|
|
200
191
|
if (this.logError) this.emit('error', `Install package for Linux Docker error: ${error}`);
|
|
201
192
|
}
|
|
193
|
+
|
|
194
|
+
for (const path of linuxCandidates) {
|
|
195
|
+
try {
|
|
196
|
+
await access(path, fs.constants.X_OK);
|
|
197
|
+
result.path = path;
|
|
198
|
+
result.system = 'Linux Docker';
|
|
199
|
+
return result;
|
|
200
|
+
} catch { }
|
|
201
|
+
}
|
|
202
202
|
}
|
|
203
203
|
}
|
|
204
204
|
|
package/src/melcloudhome.js
CHANGED
|
@@ -9,11 +9,6 @@ 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
|
-
|
|
14
|
-
import crypto from 'crypto';
|
|
15
|
-
import { URL } from 'url';
|
|
16
|
-
|
|
17
12
|
class MelCloudHome extends EventEmitter {
|
|
18
13
|
constructor(account, accountFile, buildingsFile, pluginStart = false) {
|
|
19
14
|
super();
|
|
@@ -220,7 +215,7 @@ class MelCloudHome extends EventEmitter {
|
|
|
220
215
|
}
|
|
221
216
|
}
|
|
222
217
|
|
|
223
|
-
async
|
|
218
|
+
async connect() {
|
|
224
219
|
if (this.logDebug) this.emit('debug', 'Connecting to MELCloud Home');
|
|
225
220
|
const GLOBAL_TIMEOUT = 120000;
|
|
226
221
|
|
|
@@ -445,187 +440,6 @@ class MelCloudHome extends EventEmitter {
|
|
|
445
440
|
}
|
|
446
441
|
}
|
|
447
442
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
async axiosRequest(url, options, jar, maxRedirects = 15) {
|
|
451
|
-
let currentUrl = url;
|
|
452
|
-
let previousUrl = null;
|
|
453
|
-
let method = options.method || 'GET';
|
|
454
|
-
let body = options.body;
|
|
455
|
-
|
|
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
|
-
|
|
469
|
-
const response = await axios({
|
|
470
|
-
url: currentUrl,
|
|
471
|
-
method,
|
|
472
|
-
data: body,
|
|
473
|
-
headers,
|
|
474
|
-
maxRedirects: 0,
|
|
475
|
-
validateStatus: () => true,
|
|
476
|
-
responseType: 'text',
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
jar.addFromHeader(response.headers['set-cookie'], currentUrl);
|
|
480
|
-
|
|
481
|
-
/* ========= HTTP REDIRECT ========= */
|
|
482
|
-
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
483
|
-
const location = response.headers.location;
|
|
484
|
-
if (!location) break;
|
|
485
|
-
|
|
486
|
-
// SUCCESS: app redirect
|
|
487
|
-
if (location.startsWith('melcloudhome://')) {
|
|
488
|
-
return { url: location, data: response.data };
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
previousUrl = currentUrl;
|
|
492
|
-
currentUrl = location.startsWith('http')
|
|
493
|
-
? location
|
|
494
|
-
: new URL(location, currentUrl).toString();
|
|
495
|
-
|
|
496
|
-
method = 'GET';
|
|
497
|
-
body = undefined;
|
|
498
|
-
continue;
|
|
499
|
-
}
|
|
500
|
-
|
|
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(/&/g, '&')
|
|
509
|
-
.replace(///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
|
-
};
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
throw new Error('OAuth flow aborted: too many redirects');
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
async loginWithCredentials(email, password) {
|
|
534
|
-
const jar = new CookieJar();
|
|
535
|
-
|
|
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';
|
|
537
|
-
|
|
538
|
-
const codeVerifier = crypto.randomBytes(32).toString('base64url');
|
|
539
|
-
const codeChallenge = crypto
|
|
540
|
-
.createHash('sha256')
|
|
541
|
-
.update(codeVerifier)
|
|
542
|
-
.digest('base64url');
|
|
543
|
-
|
|
544
|
-
const authUrl = 'https://auth.melcloudhome.com/connect/authorize?' +
|
|
545
|
-
new URLSearchParams({
|
|
546
|
-
client_id: 'homemobile',
|
|
547
|
-
redirect_uri: 'melcloudhome://',
|
|
548
|
-
response_type: 'code',
|
|
549
|
-
scope: 'openid profile email offline_access IdentityServerApi',
|
|
550
|
-
code_challenge: codeChallenge,
|
|
551
|
-
code_challenge_method: 'S256',
|
|
552
|
-
}).toString();
|
|
553
|
-
|
|
554
|
-
// STEP 1 – get login page
|
|
555
|
-
const loginPage = await this.axiosRequest(authUrl, {
|
|
556
|
-
headers: { 'User-Agent': userAgent },
|
|
557
|
-
}, jar);
|
|
558
|
-
|
|
559
|
-
const csrfMatch = loginPage.data.match(/name="(_csrf|__RequestVerificationToken)".*?value="([^"]+)"/);
|
|
560
|
-
if (!csrfMatch) throw new Error('CSRF token not found');
|
|
561
|
-
const csrfToken = csrfMatch[2];
|
|
562
|
-
|
|
563
|
-
// STEP 2 – submit credentials
|
|
564
|
-
const form = new URLSearchParams({
|
|
565
|
-
_csrf: csrfToken,
|
|
566
|
-
username: email,
|
|
567
|
-
password,
|
|
568
|
-
}).toString();
|
|
569
|
-
|
|
570
|
-
const loginResult = await this.axiosRequest(loginPage.url, {
|
|
571
|
-
method: 'POST',
|
|
572
|
-
headers: {
|
|
573
|
-
'User-Agent': userAgent,
|
|
574
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
575
|
-
'Content-Length': Buffer.byteLength(form),
|
|
576
|
-
},
|
|
577
|
-
body: form,
|
|
578
|
-
}, jar);
|
|
579
|
-
|
|
580
|
-
if (!loginResult.url.startsWith('melcloudhome://')) {
|
|
581
|
-
throw new Error('OAuth redirect not received');
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
const code = new URL(loginResult.url).searchParams.get('code');
|
|
585
|
-
if (!code) throw new Error('Authorization code missing');
|
|
586
|
-
|
|
587
|
-
// STEP 3 – exchange token
|
|
588
|
-
const tokenResponse = await axios.post('https://auth.melcloudhome.com/connect/token',
|
|
589
|
-
new URLSearchParams({
|
|
590
|
-
grant_type: 'authorization_code',
|
|
591
|
-
client_id: 'homemobile',
|
|
592
|
-
redirect_uri: 'melcloudhome://',
|
|
593
|
-
code,
|
|
594
|
-
code_verifier: codeVerifier,
|
|
595
|
-
}).toString(),
|
|
596
|
-
{
|
|
597
|
-
headers: {
|
|
598
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
599
|
-
'Authorization': 'Basic aG9tZW1vYmlsZTo=',
|
|
600
|
-
},
|
|
601
|
-
},
|
|
602
|
-
);
|
|
603
|
-
|
|
604
|
-
if (!tokenResponse.data.refresh_token) {
|
|
605
|
-
throw new Error('No refresh token returned');
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
return {
|
|
609
|
-
refreshToken: tokenResponse.data.refresh_token,
|
|
610
|
-
accessToken: tokenResponse.data.access_token,
|
|
611
|
-
expiresIn: tokenResponse.data.expires_in,
|
|
612
|
-
};
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
async connect() {
|
|
616
|
-
if (this.logDebug) this.emit('debug', 'Connecting to MELCloud Home');
|
|
617
|
-
|
|
618
|
-
try {
|
|
619
|
-
const accountInfo = { State: false, Info: '', Account: {}, UseFahrenheit: false };
|
|
620
|
-
|
|
621
|
-
const tokens = await this.loginWithCredentials(this.user, this.passwd);
|
|
622
|
-
this.emit('debug', `Tokens: ${JSON.stringify(tokens, null, 2)}`);
|
|
623
|
-
return accountInfo
|
|
624
|
-
} catch (error) {
|
|
625
|
-
throw new Error(`Connect error: ${error.message}`);
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
|
|
629
443
|
}
|
|
630
444
|
|
|
631
445
|
export default MelCloudHome;
|
package/src/helper.js
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
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
|