homebridge-melcloud-control 4.0.0-beta.243 → 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/melcloud.js +1 -1
- package/src/melcloudhometoken.js +95 -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/melcloud.js
CHANGED
|
@@ -176,7 +176,7 @@ class MelCloud extends EventEmitter {
|
|
|
176
176
|
|
|
177
177
|
// MELCloud Home
|
|
178
178
|
async connectToMelCloudHome() {
|
|
179
|
-
const oauth = new MelCloudHomeToken(
|
|
179
|
+
const oauth = new MelCloudHomeToken();
|
|
180
180
|
this.tokens = await oauth.getTokensWithCredentials(this.user, this.passwd);
|
|
181
181
|
this.emit('warn', `Token ${JSON.stringify(this.tokens, null, 2)}`);
|
|
182
182
|
//this.axiosInstance.defaults.headers['Authorization'] = `Bearer ${this.tokens.accessToken}`;
|
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,84 +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: {
|
|
88
|
+
'User-Agent': MOBILE_USER_AGENT,
|
|
40
89
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
41
|
-
|
|
42
|
-
},
|
|
43
|
-
validateStatus: () => true
|
|
90
|
+
}
|
|
44
91
|
});
|
|
45
92
|
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
const csrfMatch = html.match(/name="_csrf"\s+value="([^"]+)"/);
|
|
51
|
-
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');
|
|
52
96
|
|
|
53
|
-
|
|
54
|
-
return { html, loginUrl: resp.request.res.responseUrl, csrf: csrfMatch[1], cookies };
|
|
97
|
+
return { formHtml: resp.html, cookies: resp.cookies, form };
|
|
55
98
|
}
|
|
56
99
|
|
|
57
|
-
|
|
100
|
+
// --- Submit login ---
|
|
101
|
+
async submitLogin(form, email, password, cookies, loginPageUrl) {
|
|
58
102
|
console.log('Submitting login credentials...');
|
|
59
103
|
|
|
60
|
-
//
|
|
61
|
-
const resp = await this.axiosInstance.get(loginUrl, {
|
|
62
|
-
headers: { Cookie: cookies },
|
|
63
|
-
maxRedirects: 0,
|
|
64
|
-
validateStatus: s => s >= 200 && s < 400
|
|
65
|
-
}).catch(err => err.response || err);
|
|
66
|
-
|
|
67
|
-
const html = resp.data;
|
|
68
|
-
|
|
69
|
-
// 2. Parsujemy formę logowania i zbieramy hidden inputs
|
|
70
|
-
const dom = new JSDOM(html);
|
|
71
|
-
const form = dom.window.document.querySelector('form');
|
|
72
|
-
if (!form) throw new Error('Nie znaleziono formy logowania');
|
|
73
|
-
|
|
104
|
+
// Collect all hidden inputs
|
|
74
105
|
const formData = new URLSearchParams();
|
|
75
|
-
|
|
106
|
+
form.querySelectorAll('input').forEach(input => {
|
|
76
107
|
const name = input.getAttribute('name');
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
108
|
+
if (name) formData.append(name, input.getAttribute('value') || '');
|
|
109
|
+
});
|
|
80
110
|
|
|
81
|
-
//
|
|
111
|
+
// Overwrite username/password
|
|
82
112
|
formData.set('username', email);
|
|
83
113
|
formData.set('password', password);
|
|
84
114
|
|
|
85
|
-
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);
|
|
86
120
|
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
121
|
+
// Try to extract code from redirect
|
|
122
|
+
const codeFromLocation = resp.finalUrl.match(/[?&]code=([^&]+)/);
|
|
123
|
+
if (codeFromLocation) return codeFromLocation[1];
|
|
90
124
|
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
95
|
-
},
|
|
96
|
-
maxRedirects: 0,
|
|
97
|
-
validateStatus: s => s >= 200 && s < 400
|
|
98
|
-
}).catch(err => err.response || err);
|
|
99
|
-
|
|
100
|
-
// 5. Szukamy authorization code w redirect
|
|
101
|
-
const locationHeader = resp.headers?.location;
|
|
102
|
-
if (locationHeader && locationHeader.startsWith('melcloudhome://')) {
|
|
103
|
-
const codeMatch = locationHeader.match(/[?&]code=([^&]+)/);
|
|
104
|
-
if (codeMatch) return codeMatch[1];
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// 6. Albo w body (czasami JS redirect)
|
|
108
|
-
const bodyCodeMatch = resp.data?.match(/melcloudhome:\/\/[^"'\s]*[?&]code=([^&"'\s]+)/);
|
|
109
|
-
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];
|
|
110
128
|
|
|
111
129
|
throw new Error('No authorization code found after login');
|
|
112
130
|
}
|
|
113
131
|
|
|
132
|
+
// --- Exchange code for tokens ---
|
|
114
133
|
async exchangeCodeForTokens(code, codeVerifier) {
|
|
115
134
|
console.log('Exchanging authorization code for tokens...');
|
|
116
135
|
|
|
@@ -119,28 +138,27 @@ class MelCloudHomeToken {
|
|
|
119
138
|
code,
|
|
120
139
|
redirect_uri: REDIRECT_URI,
|
|
121
140
|
client_id: CLIENT_ID,
|
|
122
|
-
code_verifier: codeVerifier
|
|
141
|
+
code_verifier: codeVerifier,
|
|
123
142
|
});
|
|
124
143
|
|
|
125
|
-
const resp = await this.
|
|
144
|
+
const resp = await this.httpsRequest(TOKEN_ENDPOINT, {
|
|
145
|
+
method: 'POST',
|
|
126
146
|
headers: {
|
|
127
147
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
128
|
-
'
|
|
148
|
+
'Content-Length': tokenData.toString().length,
|
|
149
|
+
'Authorization': 'Basic aG9tZW1vYmlsZTo=', // base64(homemobile:)
|
|
129
150
|
}
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
if (resp.data.error) throw new Error(`Token exchange failed: ${resp.data.error_description || resp.data.error}`);
|
|
151
|
+
}, tokenData.toString());
|
|
133
152
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
expiresIn: resp.data.expires_in,
|
|
138
|
-
idToken: resp.data.id_token
|
|
139
|
-
};
|
|
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;
|
|
140
156
|
}
|
|
141
157
|
|
|
158
|
+
// --- Main flow ---
|
|
142
159
|
async getTokensWithCredentials(email, password) {
|
|
143
160
|
console.log('Obtaining OAuth tokens automatically...');
|
|
161
|
+
|
|
144
162
|
const pkce = this.generatePKCE();
|
|
145
163
|
const state = this.generateState();
|
|
146
164
|
|
|
@@ -153,10 +171,14 @@ class MelCloudHomeToken {
|
|
|
153
171
|
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
154
172
|
authUrl.searchParams.set('state', state);
|
|
155
173
|
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
const tokens = await this.exchangeCodeForTokens(authCode, pkce.verifier);
|
|
174
|
+
// 1️⃣ get login page
|
|
175
|
+
const { form, cookies } = await this.getLoginPage(authUrl.toString());
|
|
159
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);
|
|
160
182
|
console.log('✓ Successfully obtained OAuth tokens');
|
|
161
183
|
return tokens;
|
|
162
184
|
}
|