homebridge-melcloud-control 4.0.0-beta.250 → 4.0.0-beta.252
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 +28 -191
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.252",
|
|
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,225 +1,62 @@
|
|
|
1
|
-
// oauth-helper-axios.js
|
|
2
1
|
import https from 'https';
|
|
3
|
-
import
|
|
4
|
-
import { URL, URLSearchParams } from 'url';
|
|
5
|
-
import { JSDOM } from 'jsdom';
|
|
2
|
+
import { URLSearchParams } from 'url';
|
|
6
3
|
|
|
7
|
-
const
|
|
4
|
+
const TOKEN_ENDPOINT = 'https://auth.melcloudhome.com/connect/token';
|
|
8
5
|
const CLIENT_ID = 'homemobile';
|
|
9
|
-
const REDIRECT_URI = 'melcloudhome://';
|
|
6
|
+
const REDIRECT_URI = 'melcloudhome://signin-oidc-meu';
|
|
10
7
|
const SCOPE = 'openid profile email offline_access IdentityServerApi';
|
|
11
|
-
const TOKEN_ENDPOINT = 'https://auth.melcloudhome.com/connect/token';
|
|
12
|
-
const AUTHORIZE_ENDPOINT = 'https://auth.melcloudhome.com/connect/authorize';
|
|
13
8
|
|
|
14
9
|
class MelCloudHomeToken {
|
|
15
|
-
|
|
16
|
-
this.log = log;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
// --- PKCE ---
|
|
20
|
-
generatePKCE() {
|
|
21
|
-
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
22
|
-
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
23
|
-
return { verifier, challenge };
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
generateState() {
|
|
27
|
-
return crypto.randomBytes(32).toString('hex');
|
|
28
|
-
}
|
|
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
|
-
|
|
10
|
+
httpsRequest(url, options = {}, body = undefined) {
|
|
34
11
|
return new Promise((resolve, reject) => {
|
|
35
|
-
const
|
|
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
|
-
const setCookie = res.headers['set-cookie'];
|
|
47
|
-
if (setCookie) {
|
|
48
|
-
setCookie.forEach(c => {
|
|
49
|
-
const name = c.split('=')[0];
|
|
50
|
-
const idx = cookies.findIndex(x => x.startsWith(name + '='));
|
|
51
|
-
if (idx >= 0) cookies[idx] = c.split(';')[0];
|
|
52
|
-
else cookies.push(c.split(';')[0]);
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
if ([301, 302, 303].includes(res.statusCode) && res.headers.location) {
|
|
57
|
-
const redirectUrl = res.headers.location.startsWith('http')
|
|
58
|
-
? res.headers.location
|
|
59
|
-
: `https://${urlObj.hostname}${res.headers.location}`;
|
|
60
|
-
return resolve(this.httpsRequest(redirectUrl, options, undefined, cookies, redirectCount + 1));
|
|
61
|
-
}
|
|
62
|
-
|
|
12
|
+
const req = https.request(url, options, (res) => {
|
|
63
13
|
let data = '';
|
|
64
14
|
res.on('data', chunk => data += chunk);
|
|
65
|
-
res.on('end', () => resolve({
|
|
15
|
+
res.on('end', () => resolve({ statusCode: res.statusCode, body: data }));
|
|
66
16
|
});
|
|
67
|
-
|
|
68
17
|
req.on('error', reject);
|
|
69
18
|
if (body) req.write(body);
|
|
70
19
|
req.end();
|
|
71
20
|
});
|
|
72
21
|
}
|
|
73
22
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
'User-Agent': MOBILE_USER_AGENT,
|
|
81
|
-
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
82
|
-
}
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
const dom = new JSDOM(resp.html);
|
|
86
|
-
const form = dom.window.document.querySelector('form');
|
|
87
|
-
if (!form) throw new Error('Login form not found');
|
|
88
|
-
|
|
89
|
-
return { form, cookies: resp.cookies, loginPageUrl: resp.finalUrl };
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// --- Submit login with debug ---
|
|
93
|
-
async submitLogin(form, email, password, cookies, loginPageUrl) {
|
|
94
|
-
console.log('Submitting login credentials...');
|
|
95
|
-
|
|
96
|
-
// 1️⃣ Zbieramy wszystkie hidden inputy
|
|
97
|
-
const formData = new URLSearchParams();
|
|
98
|
-
form.querySelectorAll('input').forEach(input => {
|
|
99
|
-
const name = input.getAttribute('name');
|
|
100
|
-
if (name) formData.append(name, input.getAttribute('value') || '');
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
// Overwrite username/password
|
|
104
|
-
formData.set('username', email);
|
|
105
|
-
formData.set('password', password);
|
|
106
|
-
|
|
107
|
-
console.log('Login form data (hidden + credentials):');
|
|
108
|
-
for (const [key, value] of formData.entries()) {
|
|
109
|
-
console.log(` ${key} = ${value}`);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const actionUrl = new URL(form.getAttribute('action'), loginPageUrl).toString();
|
|
113
|
-
const resp = await this.httpsRequest(actionUrl, {
|
|
114
|
-
method: 'POST',
|
|
115
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
|
116
|
-
}, formData.toString(), cookies);
|
|
117
|
-
|
|
118
|
-
// 2️⃣ Obsługa form_post (Cognito auto-submit)
|
|
119
|
-
const domPost = new JSDOM(resp.html);
|
|
120
|
-
const postForm = domPost.window.document.querySelector('form');
|
|
121
|
-
if (postForm) {
|
|
122
|
-
const postData = new URLSearchParams();
|
|
123
|
-
postForm.querySelectorAll('input').forEach(input => {
|
|
124
|
-
const name = input.getAttribute('name');
|
|
125
|
-
if (name) postData.append(name, input.getAttribute('value') || '');
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
console.log('Form_post hidden inputs:');
|
|
129
|
-
for (const [key, value] of postData.entries()) {
|
|
130
|
-
console.log(` ${key} = ${value}`);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const postAction = new URL(postForm.getAttribute('action'), resp.finalUrl).toString();
|
|
134
|
-
const postResp = await this.httpsRequest(postAction, {
|
|
135
|
-
method: 'POST',
|
|
136
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
|
137
|
-
}, postData.toString(), cookies);
|
|
138
|
-
|
|
139
|
-
// 3️⃣ Wyciągamy authorization code
|
|
140
|
-
let codeMatch = postResp.finalUrl.match(/[?&]code=([^&]+)/);
|
|
141
|
-
if (!codeMatch) codeMatch = postResp.html.match(/melcloudhome:\/\/[^"'\s]*[?&]code=([^&"'\s]+)/);
|
|
142
|
-
|
|
143
|
-
if (codeMatch) {
|
|
144
|
-
const code = codeMatch[1];
|
|
145
|
-
console.log(`Authorization code obtained: ${code}`);
|
|
146
|
-
return code;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Fallback: sprawdzamy resp finalUrl i body
|
|
151
|
-
let codeMatch = resp.finalUrl.match(/[?&]code=([^&]+)/);
|
|
152
|
-
if (!codeMatch) codeMatch = resp.html.match(/melcloudhome:\/\/[^"'\s]*[?&]code=([^&"'\s]+)/);
|
|
153
|
-
if (codeMatch) {
|
|
154
|
-
const code = codeMatch[1];
|
|
155
|
-
console.log(`Authorization code obtained: ${code}`);
|
|
156
|
-
return code;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
throw new Error('No authorization code found after login');
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// --- Exchange code for tokens (debug) ---
|
|
163
|
-
async exchangeCodeForTokens(code, codeVerifier) {
|
|
164
|
-
console.log('Exchanging authorization code for tokens...');
|
|
165
|
-
console.log(`Authorization code: ${code}`);
|
|
166
|
-
console.log(`Code verifier: ${codeVerifier}`);
|
|
23
|
+
/**
|
|
24
|
+
* Get OAuth tokens using username/password directly (ROPC)
|
|
25
|
+
* Works in Node.js / Docker, no Puppeteer needed
|
|
26
|
+
*/
|
|
27
|
+
async getTokensWithPassword(username, password) {
|
|
28
|
+
console.log('Obtaining OAuth tokens using password grant...');
|
|
167
29
|
|
|
168
|
-
const
|
|
169
|
-
grant_type: '
|
|
170
|
-
code,
|
|
171
|
-
redirect_uri: REDIRECT_URI,
|
|
30
|
+
const params = new URLSearchParams({
|
|
31
|
+
grant_type: 'password',
|
|
172
32
|
client_id: CLIENT_ID,
|
|
173
|
-
|
|
33
|
+
username,
|
|
34
|
+
password,
|
|
35
|
+
scope: SCOPE,
|
|
174
36
|
});
|
|
175
37
|
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}, tokenData.toString());
|
|
38
|
+
const headers = {
|
|
39
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
40
|
+
'Content-Length': params.toString().length,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const resp = await this.httpsRequest(TOKEN_ENDPOINT, { method: 'POST', headers }, params.toString());
|
|
183
44
|
|
|
184
|
-
console.log('Token endpoint response:', resp.
|
|
45
|
+
console.log('Token endpoint response:', resp.body);
|
|
185
46
|
|
|
186
|
-
|
|
187
|
-
try { tokens = JSON.parse(resp.html); }
|
|
188
|
-
catch (err) { console.log('Failed to parse token response'); throw err; }
|
|
47
|
+
const tokens = JSON.parse(resp.body);
|
|
189
48
|
|
|
190
49
|
if (tokens.error) {
|
|
191
|
-
|
|
192
|
-
throw new Error(`Token
|
|
50
|
+
console.error('Token response error:', tokens);
|
|
51
|
+
throw new Error(`Token request failed: ${tokens.error_description || tokens.error}`);
|
|
193
52
|
}
|
|
194
53
|
|
|
195
|
-
return tokens;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// --- Main flow ---
|
|
199
|
-
async getTokensWithCredentials(email, password) {
|
|
200
|
-
console.log('Obtaining OAuth tokens automatically...');
|
|
201
|
-
const pkce = this.generatePKCE();
|
|
202
|
-
const state = this.generateState();
|
|
203
|
-
|
|
204
|
-
const authUrl = new URL(AUTHORIZE_ENDPOINT);
|
|
205
|
-
authUrl.searchParams.set('client_id', CLIENT_ID);
|
|
206
|
-
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
|
|
207
|
-
authUrl.searchParams.set('response_type', 'code');
|
|
208
|
-
authUrl.searchParams.set('scope', SCOPE);
|
|
209
|
-
authUrl.searchParams.set('code_challenge', pkce.challenge);
|
|
210
|
-
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
211
|
-
authUrl.searchParams.set('state', state);
|
|
212
|
-
|
|
213
|
-
const { form, cookies, loginPageUrl } = await this.getLoginPage(authUrl.toString());
|
|
214
|
-
const authCode = await this.submitLogin(form, email, password, cookies, loginPageUrl);
|
|
215
|
-
const tokens = await this.exchangeCodeForTokens(authCode, pkce.verifier);
|
|
216
|
-
|
|
217
54
|
console.log('✓ Successfully obtained OAuth tokens');
|
|
218
55
|
return tokens;
|
|
219
56
|
}
|
|
220
|
-
|
|
221
57
|
}
|
|
222
58
|
|
|
59
|
+
|
|
223
60
|
export default MelCloudHomeToken;
|
|
224
61
|
|
|
225
62
|
|