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