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