homebridge-melcloud-control 4.0.0-beta.244 → 4.0.0-beta.246
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 +1 -1
- package/src/melcloudhometoken.js +96 -81
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"displayName": "MELCloud Control",
|
|
3
3
|
"name": "homebridge-melcloud-control",
|
|
4
|
-
"version": "4.0.0-beta.
|
|
4
|
+
"version": "4.0.0-beta.246",
|
|
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/melcloudhometoken.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// oauth-helper-axios.js
|
|
2
|
-
import
|
|
3
|
-
import { CookieJar } from 'tough-cookie';
|
|
4
|
-
import { wrapper } from 'axios-cookiejar-support';
|
|
2
|
+
import https from 'https';
|
|
5
3
|
import crypto from 'crypto';
|
|
4
|
+
import { URL, URLSearchParams } from 'url';
|
|
6
5
|
import { JSDOM } from 'jsdom';
|
|
7
6
|
|
|
8
7
|
const MOBILE_USER_AGENT = 'MonitorAndControl.App.Mobile/35 CFNetwork/3860.100.1 Darwin/25.0.0';
|
|
@@ -13,16 +12,11 @@ const TOKEN_ENDPOINT = 'https://auth.melcloudhome.com/connect/token';
|
|
|
13
12
|
const AUTHORIZE_ENDPOINT = 'https://auth.melcloudhome.com/connect/authorize';
|
|
14
13
|
|
|
15
14
|
class MelCloudHomeToken {
|
|
16
|
-
constructor(
|
|
17
|
-
|
|
18
|
-
this.axiosInstance = wrapper(axios.create({
|
|
19
|
-
jar: this.jar,
|
|
20
|
-
withCredentials: true,
|
|
21
|
-
headers: { 'User-Agent': MOBILE_USER_AGENT },
|
|
22
|
-
maxRedirects: 10
|
|
23
|
-
}));
|
|
15
|
+
constructor() {
|
|
16
|
+
|
|
24
17
|
}
|
|
25
18
|
|
|
19
|
+
// --- PKCE ---
|
|
26
20
|
generatePKCE() {
|
|
27
21
|
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
28
22
|
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
@@ -33,85 +27,103 @@ class MelCloudHomeToken {
|
|
|
33
27
|
return crypto.randomBytes(32).toString('hex');
|
|
34
28
|
}
|
|
35
29
|
|
|
30
|
+
// --- HTTPS request with cookies ---
|
|
31
|
+
httpsRequest(url, options = {}, body = undefined, cookies = [], redirectCount = 0) {
|
|
32
|
+
if (redirectCount > 10) throw new Error('Too many redirects');
|
|
33
|
+
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const urlObj = new URL(url);
|
|
36
|
+
const reqOptions = {
|
|
37
|
+
...options,
|
|
38
|
+
hostname: urlObj.hostname,
|
|
39
|
+
path: urlObj.pathname + urlObj.search,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (!reqOptions.headers) reqOptions.headers = {};
|
|
43
|
+
if (cookies.length > 0) reqOptions.headers['Cookie'] = cookies.join('; ');
|
|
44
|
+
|
|
45
|
+
const req = https.request(reqOptions, (res) => {
|
|
46
|
+
// collect Set-Cookie
|
|
47
|
+
const setCookie = res.headers['set-cookie'];
|
|
48
|
+
if (setCookie) {
|
|
49
|
+
setCookie.forEach(c => {
|
|
50
|
+
const name = c.split('=')[0];
|
|
51
|
+
const idx = cookies.findIndex(x => x.startsWith(name + '='));
|
|
52
|
+
if (idx >= 0) cookies[idx] = c.split(';')[0];
|
|
53
|
+
else cookies.push(c.split(';')[0]);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// follow redirect
|
|
58
|
+
if ([301, 302, 303].includes(res.statusCode) && res.headers.location) {
|
|
59
|
+
const redirectUrl = res.headers.location.startsWith('http')
|
|
60
|
+
? res.headers.location
|
|
61
|
+
: `https://${urlObj.hostname}${res.headers.location}`;
|
|
62
|
+
return resolve(this.httpsRequest(redirectUrl, options, undefined, cookies, redirectCount + 1));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let data = '';
|
|
66
|
+
res.on('data', chunk => data += chunk);
|
|
67
|
+
res.on('end', () => resolve({ html: data, cookies, finalUrl: url }));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
req.on('error', reject);
|
|
71
|
+
if (body) req.write(body);
|
|
72
|
+
req.end();
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- Get login page ---
|
|
36
77
|
async getLoginPage(authUrl) {
|
|
37
78
|
console.log('Getting login page...');
|
|
38
|
-
const resp = await this.
|
|
79
|
+
const resp = await this.httpsRequest(authUrl, {
|
|
80
|
+
method: 'GET',
|
|
39
81
|
headers: {
|
|
40
82
|
'User-Agent': MOBILE_USER_AGENT,
|
|
41
83
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
42
|
-
|
|
43
|
-
},
|
|
44
|
-
validateStatus: () => true
|
|
84
|
+
}
|
|
45
85
|
});
|
|
46
86
|
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
const csrfMatch = html.match(/name="_csrf"\s+value="([^"]+)"/);
|
|
52
|
-
if (!csrfMatch) throw new Error('Could not extract CSRF token from login page');
|
|
87
|
+
const dom = new JSDOM(resp.html);
|
|
88
|
+
const form = dom.window.document.querySelector('form');
|
|
89
|
+
if (!form) throw new Error('Login form not found');
|
|
53
90
|
|
|
54
|
-
|
|
55
|
-
return { html, loginUrl: resp.request.res.responseUrl, csrf: csrfMatch[1], cookies };
|
|
91
|
+
return { formHtml: resp.html, cookies: resp.cookies, form };
|
|
56
92
|
}
|
|
57
93
|
|
|
58
|
-
|
|
94
|
+
// --- Submit login ---
|
|
95
|
+
async submitLogin(form, email, password, cookies, loginPageUrl) {
|
|
59
96
|
console.log('Submitting login credentials...');
|
|
60
97
|
|
|
61
|
-
//
|
|
62
|
-
const resp = await this.axiosInstance.get(loginUrl, {
|
|
63
|
-
headers: { Cookie: cookies },
|
|
64
|
-
maxRedirects: 0,
|
|
65
|
-
validateStatus: s => s >= 200 && s < 400
|
|
66
|
-
}).catch(err => err.response || err);
|
|
67
|
-
|
|
68
|
-
const html = resp.data;
|
|
69
|
-
|
|
70
|
-
// 2. Parsujemy formę logowania i zbieramy hidden inputs
|
|
71
|
-
const dom = new JSDOM(html);
|
|
72
|
-
const form = dom.window.document.querySelector('form');
|
|
73
|
-
if (!form) throw new Error('Nie znaleziono formy logowania');
|
|
74
|
-
|
|
98
|
+
// Collect all hidden inputs
|
|
75
99
|
const formData = new URLSearchParams();
|
|
76
|
-
|
|
100
|
+
form.querySelectorAll('input').forEach(input => {
|
|
77
101
|
const name = input.getAttribute('name');
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
102
|
+
if (name) formData.append(name, input.getAttribute('value') || '');
|
|
103
|
+
});
|
|
81
104
|
|
|
82
|
-
//
|
|
105
|
+
// Overwrite username/password
|
|
83
106
|
formData.set('username', email);
|
|
84
107
|
formData.set('password', password);
|
|
85
108
|
|
|
86
|
-
const actionUrl = new URL(form.getAttribute('action'),
|
|
109
|
+
const actionUrl = new URL(form.getAttribute('action'), loginPageUrl).toString();
|
|
110
|
+
const resp = await this.httpsRequest(actionUrl, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
|
113
|
+
}, formData.toString(), cookies);
|
|
87
114
|
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
115
|
+
// Try to extract code from redirect
|
|
116
|
+
const codeFromLocation = resp.finalUrl.match(/[?&]code=([^&]+)/);
|
|
117
|
+
if (codeFromLocation) return codeFromLocation[1];
|
|
91
118
|
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
96
|
-
},
|
|
97
|
-
maxRedirects: 0,
|
|
98
|
-
validateStatus: s => s >= 200 && s < 400
|
|
99
|
-
}).catch(err => err.response || err);
|
|
100
|
-
|
|
101
|
-
// 5. Szukamy authorization code w redirect
|
|
102
|
-
const locationHeader = resp.headers?.location;
|
|
103
|
-
if (locationHeader && locationHeader.startsWith('melcloudhome://')) {
|
|
104
|
-
const codeMatch = locationHeader.match(/[?&]code=([^&]+)/);
|
|
105
|
-
if (codeMatch) return codeMatch[1];
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// 6. Albo w body (czasami JS redirect)
|
|
109
|
-
const bodyCodeMatch = resp.data?.match(/melcloudhome:\/\/[^"'\s]*[?&]code=([^&"'\s]+)/);
|
|
110
|
-
if (bodyCodeMatch) return bodyCodeMatch[1];
|
|
119
|
+
// Or from body (JS redirect or hidden form_post)
|
|
120
|
+
const codeFromBody = resp.html.match(/melcloudhome:\/\/[^"'\s]*[?&]code=([^&"'\s]+)/);
|
|
121
|
+
if (codeFromBody) return codeFromBody[1];
|
|
111
122
|
|
|
112
123
|
throw new Error('No authorization code found after login');
|
|
113
124
|
}
|
|
114
125
|
|
|
126
|
+
// --- Exchange code for tokens ---
|
|
115
127
|
async exchangeCodeForTokens(code, codeVerifier) {
|
|
116
128
|
console.log('Exchanging authorization code for tokens...');
|
|
117
129
|
|
|
@@ -120,28 +132,27 @@ class MelCloudHomeToken {
|
|
|
120
132
|
code,
|
|
121
133
|
redirect_uri: REDIRECT_URI,
|
|
122
134
|
client_id: CLIENT_ID,
|
|
123
|
-
code_verifier: codeVerifier
|
|
135
|
+
code_verifier: codeVerifier,
|
|
124
136
|
});
|
|
125
137
|
|
|
126
|
-
const resp = await this.
|
|
138
|
+
const resp = await this.httpsRequest(TOKEN_ENDPOINT, {
|
|
139
|
+
method: 'POST',
|
|
127
140
|
headers: {
|
|
128
141
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
129
|
-
'
|
|
142
|
+
'Content-Length': tokenData.toString().length,
|
|
143
|
+
'Authorization': 'Basic aG9tZW1vYmlsZTo=', // base64(homemobile:)
|
|
130
144
|
}
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
if (resp.data.error) throw new Error(`Token exchange failed: ${resp.data.error_description || resp.data.error}`);
|
|
145
|
+
}, tokenData.toString());
|
|
134
146
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
expiresIn: resp.data.expires_in,
|
|
139
|
-
idToken: resp.data.id_token
|
|
140
|
-
};
|
|
147
|
+
const tokens = JSON.parse(resp.html);
|
|
148
|
+
if (tokens.error) throw new Error(`Token exchange failed: ${tokens.error_description || tokens.error}`);
|
|
149
|
+
return tokens;
|
|
141
150
|
}
|
|
142
151
|
|
|
152
|
+
// --- Main flow ---
|
|
143
153
|
async getTokensWithCredentials(email, password) {
|
|
144
154
|
console.log('Obtaining OAuth tokens automatically...');
|
|
155
|
+
|
|
145
156
|
const pkce = this.generatePKCE();
|
|
146
157
|
const state = this.generateState();
|
|
147
158
|
|
|
@@ -154,10 +165,14 @@ class MelCloudHomeToken {
|
|
|
154
165
|
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
155
166
|
authUrl.searchParams.set('state', state);
|
|
156
167
|
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
const tokens = await this.exchangeCodeForTokens(authCode, pkce.verifier);
|
|
168
|
+
// 1️⃣ get login page
|
|
169
|
+
const { form, cookies } = await this.getLoginPage(authUrl.toString());
|
|
160
170
|
|
|
171
|
+
// 2️⃣ submit login
|
|
172
|
+
const authCode = await this.submitLogin(form, email, password, cookies, authUrl.toString());
|
|
173
|
+
|
|
174
|
+
// 3️⃣ exchange code for tokens
|
|
175
|
+
const tokens = await this.exchangeCodeForTokens(authCode, pkce.verifier);
|
|
161
176
|
console.log('✓ Successfully obtained OAuth tokens');
|
|
162
177
|
return tokens;
|
|
163
178
|
}
|