homebridge-melcloud-control 4.0.0-beta.397 → 4.0.0-beta.399
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 +6 -6
- package/src/melcloudhometoken.js +177 -187
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.399",
|
|
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
|
@@ -277,7 +277,7 @@ class MelCloud extends EventEmitter {
|
|
|
277
277
|
}
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
-
async
|
|
280
|
+
async connectToMelCloudHome() {
|
|
281
281
|
try {
|
|
282
282
|
const melCloudHomeToken = new MelCloudHomeToken({
|
|
283
283
|
user: this.user,
|
|
@@ -289,11 +289,11 @@ class MelCloud extends EventEmitter {
|
|
|
289
289
|
.on('warn', warn => this.emit('warn', warn))
|
|
290
290
|
.on('error', error => this.emit('error', error));
|
|
291
291
|
|
|
292
|
-
const { codeVerifier, url } = await melCloudHomeToken.buildAuthorizeUrl();
|
|
293
|
-
const code = await melCloudHomeToken.loginToMelCloudHome(url);
|
|
294
|
-
const token = await melCloudHomeToken.getTokens(
|
|
292
|
+
//const { codeVerifier, url } = await melCloudHomeToken.buildAuthorizeUrl();
|
|
293
|
+
//const code = await melCloudHomeToken.loginToMelCloudHome(url);
|
|
294
|
+
const token = await melCloudHomeToken.getTokens(this.user, this.passwd);
|
|
295
295
|
|
|
296
|
-
const accountInfo = { ContextKey:
|
|
296
|
+
const accountInfo = { ContextKey: token, UseFahrenheit: false };
|
|
297
297
|
this.contextKey = code;
|
|
298
298
|
|
|
299
299
|
return accountInfo
|
|
@@ -302,7 +302,7 @@ class MelCloud extends EventEmitter {
|
|
|
302
302
|
}
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
-
async
|
|
305
|
+
async connectToMelCloudHome1(refresh = false) {
|
|
306
306
|
if (this.logDebug) this.emit('debug', `Connecting to MELCloud Home`);
|
|
307
307
|
|
|
308
308
|
let browser;
|
package/src/melcloudhometoken.js
CHANGED
|
@@ -1,227 +1,217 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
2
|
import crypto from 'crypto';
|
|
3
|
-
import { wrapper } from 'axios-cookiejar-support';
|
|
4
3
|
import { CookieJar } from 'tough-cookie';
|
|
5
|
-
import {
|
|
6
|
-
import EventEmitter from 'events';
|
|
7
|
-
|
|
8
|
-
const MOBILE_USER_AGENT = 'MonitorAndControl.App.Mobile/35 CFNetwork/3860.100.1 Darwin/25.0.0';
|
|
9
|
-
const CLIENT_ID = 'homemobile';
|
|
10
|
-
const REDIRECT_URI = 'melcloudhome://';
|
|
11
|
-
const SCOPE = 'openid profile email offline_access IdentityServerApi';
|
|
12
|
-
const TOKEN_ENDPOINT = 'https://auth.melcloudhome.com/connect/token';
|
|
13
|
-
const AUTHORIZE_ENDPOINT = 'https://auth.melcloudhome.com/connect/authorize';
|
|
14
|
-
|
|
15
|
-
class MelCloudHomeToken extends EventEmitter {
|
|
16
|
-
constructor(config) {
|
|
17
|
-
super();
|
|
18
|
-
this.user = config.user;
|
|
19
|
-
this.passwd = config.passwd;
|
|
20
|
-
this.logWarn = config.logWarn;
|
|
21
|
-
this.logError = config.logError;
|
|
22
|
-
|
|
23
|
-
const jar = new CookieJar();
|
|
24
|
-
this.client = wrapper(axios.create({ jar, withCredentials: true }));
|
|
25
|
-
this.client.defaults.headers['User-Agent'] = MOBILE_USER_AGENT;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
generatePKCE() {
|
|
29
|
-
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
30
|
-
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
31
|
-
return { verifier, challenge };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
generateState() {
|
|
35
|
-
return crypto.randomBytes(32).toString('hex');
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
async buildAuthorizeUrl() {
|
|
39
|
-
const pkce = this.generatePKCE();
|
|
40
|
-
const state = this.generateState();
|
|
4
|
+
import { wrapper } from 'axios-cookiejar-support';
|
|
41
5
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
6
|
+
class MelCloudHomeToken {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.MOBILE_USER_AGENT = 'MonitorAndControl.App.Mobile/35 CFNetwork/3860.100.1 Darwin/25.0.0';
|
|
9
|
+
this.CLIENT_ID = 'homemobile';
|
|
10
|
+
this.REDIRECT_URI = 'melcloudhome://';
|
|
11
|
+
this.SCOPE = 'openid profile email offline_access IdentityServerApi';
|
|
12
|
+
this.AUTH_URL = 'https://auth.melcloudhome.com/connect/authorize';
|
|
13
|
+
this.TOKEN_URL = 'https://auth.melcloudhome.com/connect/token';
|
|
50
14
|
|
|
51
|
-
|
|
15
|
+
// Axios with cookie support
|
|
16
|
+
const jar = new CookieJar();
|
|
17
|
+
this.client = wrapper(axios.create({
|
|
18
|
+
jar,
|
|
19
|
+
withCredentials: true,
|
|
20
|
+
maxRedirects: 0, // we’ll handle redirects manually
|
|
21
|
+
validateStatus: null,
|
|
22
|
+
headers: {
|
|
23
|
+
'User-Agent': this.MOBILE_USER_AGENT,
|
|
24
|
+
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
52
27
|
}
|
|
53
28
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Entry point: logs in and returns tokens
|
|
31
|
+
*/
|
|
32
|
+
async getTokens(email, password) {
|
|
33
|
+
const { codeVerifier, codeChallenge } = this.generatePKCE();
|
|
34
|
+
const state = crypto.randomBytes(32).toString('hex');
|
|
35
|
+
|
|
36
|
+
// Step 1: Authorization URL
|
|
37
|
+
const authUrl = `${this.AUTH_URL}?client_id=${this.CLIENT_ID}` +
|
|
38
|
+
`&redirect_uri=${encodeURIComponent(this.REDIRECT_URI)}` +
|
|
39
|
+
`&response_type=code` +
|
|
40
|
+
`&scope=${encodeURIComponent(this.SCOPE)}` +
|
|
41
|
+
`&code_challenge=${codeChallenge}` +
|
|
42
|
+
`&code_challenge_method=S256` +
|
|
43
|
+
`&state=${state}`;
|
|
44
|
+
|
|
45
|
+
console.log('[OAuth] Step 1: GET login page');
|
|
46
|
+
const loginPage = await this.client.get(authUrl);
|
|
47
|
+
if (loginPage.status !== 200) {
|
|
48
|
+
throw new Error(`Unexpected status on login page: ${loginPage.status}`);
|
|
49
|
+
}
|
|
65
50
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
51
|
+
// Extract CSRF token
|
|
52
|
+
const csrfMatch = loginPage.data.match(/name="_csrf"\s+value="([^"]+)"/);
|
|
53
|
+
if (!csrfMatch) throw new Error('Could not find CSRF token');
|
|
54
|
+
const csrf = csrfMatch[1];
|
|
55
|
+
console.log('[OAuth] Found CSRF token');
|
|
56
|
+
|
|
57
|
+
// Step 2: POST credentials
|
|
58
|
+
const formData = new URLSearchParams({
|
|
59
|
+
_csrf: csrf,
|
|
60
|
+
username: email,
|
|
61
|
+
password: password
|
|
62
|
+
});
|
|
71
63
|
|
|
72
|
-
|
|
64
|
+
console.log('[OAuth] Step 2: POST login credentials');
|
|
65
|
+
const loginResponse = await this.followRedirects(
|
|
66
|
+
loginPage.request.res.responseUrl,
|
|
67
|
+
{
|
|
68
|
+
method: 'POST',
|
|
69
|
+
data: formData.toString(),
|
|
73
70
|
headers: {
|
|
74
|
-
'User-Agent': MOBILE_USER_AGENT,
|
|
75
|
-
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
76
|
-
'Accept-Language': 'en-US,en;q=0.9',
|
|
71
|
+
'User-Agent': this.MOBILE_USER_AGENT,
|
|
77
72
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
78
|
-
'Content-Length': formData.toString().length,
|
|
79
|
-
'Cookie': cookies.join('; '),
|
|
80
73
|
'Origin': 'https://live-melcloudhome.auth.eu-west-1.amazoncognito.com',
|
|
81
|
-
'Referer':
|
|
82
|
-
}
|
|
83
|
-
maxRedirects: 0,
|
|
84
|
-
validateStatus: status => [200, 302, 400].includes(status)
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
if (response.status === 400) {
|
|
88
|
-
this.emit('warn', `Login failed: ${response.data}`);
|
|
89
|
-
return null;
|
|
74
|
+
'Referer': loginPage.request.res.responseUrl,
|
|
75
|
+
}
|
|
90
76
|
}
|
|
77
|
+
);
|
|
91
78
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
);
|
|
79
|
+
if (!loginResponse.url.startsWith('melcloudhome://')) {
|
|
80
|
+
console.log('[OAuth] ❌ No melcloudhome:// redirect found');
|
|
81
|
+
console.log('[OAuth] Final URL:', loginResponse.url);
|
|
82
|
+
throw new Error('OAuth flow failed - did not reach redirect URI');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const codeMatch = loginResponse.url.match(/[?&]code=([^&]+)/);
|
|
86
|
+
if (!codeMatch) throw new Error('No authorization code found');
|
|
87
|
+
const authCode = codeMatch[1];
|
|
88
|
+
console.log('[OAuth] ✓ Got authorization code');
|
|
104
89
|
|
|
105
|
-
|
|
106
|
-
|
|
90
|
+
// Step 3: Exchange code for tokens
|
|
91
|
+
console.log('[OAuth] Step 3: Exchange code for tokens');
|
|
92
|
+
const tokenData = new URLSearchParams({
|
|
93
|
+
grant_type: 'authorization_code',
|
|
94
|
+
code: authCode,
|
|
95
|
+
redirect_uri: this.REDIRECT_URI,
|
|
96
|
+
client_id: this.CLIENT_ID,
|
|
97
|
+
code_verifier: codeVerifier
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const tokenResponse = await this.client.post(this.TOKEN_URL, tokenData.toString(), {
|
|
101
|
+
headers: {
|
|
102
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
103
|
+
'Authorization': 'Basic aG9tZW1vYmlsZTo=', // homemobile:
|
|
104
|
+
}
|
|
105
|
+
});
|
|
107
106
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
107
|
+
if (tokenResponse.status !== 200) {
|
|
108
|
+
console.error('[OAuth] Token error response:', tokenResponse.data);
|
|
109
|
+
throw new Error(`Token exchange failed (HTTP ${tokenResponse.status})`);
|
|
111
110
|
}
|
|
111
|
+
|
|
112
|
+
console.log('[OAuth] ✓ Token exchange successful');
|
|
113
|
+
return {
|
|
114
|
+
refreshToken: tokenResponse.data.refresh_token,
|
|
115
|
+
accessToken: tokenResponse.data.access_token,
|
|
116
|
+
expiresIn: tokenResponse.data.expires_in,
|
|
117
|
+
};
|
|
112
118
|
}
|
|
113
119
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
// 1️⃣ Location header
|
|
120
|
-
if (locationHeader && locationHeader.startsWith('melcloudhome://')) {
|
|
121
|
-
const match = locationHeader.match(/[?&]code=([^&]+)/);
|
|
122
|
-
if (match) {
|
|
123
|
-
this.emit('warn', `Found code in Location header: ${match[1]}`);
|
|
124
|
-
resolve(match[1]);
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
120
|
+
/**
|
|
121
|
+
* Follow redirects manually (handles IdentityServer form_post, /Redirect, etc.)
|
|
122
|
+
*/
|
|
123
|
+
async followRedirects(url, options = {}, depth = 0) {
|
|
124
|
+
if (depth > 10) throw new Error('Too many redirects');
|
|
128
125
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const formStateMatch = data.match(/name="state"\s+value="([^"]+)"/);
|
|
132
|
-
const formActionMatch = data.match(/action="([^"]+)"/);
|
|
133
|
-
|
|
134
|
-
if (formCodeMatch && formStateMatch && formActionMatch) {
|
|
135
|
-
this.emit('warn', 'Found form_post, submitting...');
|
|
136
|
-
try {
|
|
137
|
-
const code = await this.submitFormPost(formActionMatch[1], formCodeMatch[1], formStateMatch[1]);
|
|
138
|
-
this.emit('warn', `submitFormPost returned code: ${code}`);
|
|
139
|
-
resolve(code);
|
|
140
|
-
return;
|
|
141
|
-
} catch (err) {
|
|
142
|
-
this.emit('warn', `submitFormPost failed: ${err}`);
|
|
143
|
-
reject(err);
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
126
|
+
const response = await this.client.request({ url, ...options });
|
|
127
|
+
const status = response.status;
|
|
147
128
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
resolve(bodyCodeMatch[1]);
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
129
|
+
// Check for melcloudhome://
|
|
130
|
+
if (response.headers.location?.startsWith('melcloudhome://')) {
|
|
131
|
+
return { url: response.headers.location, data: response.data };
|
|
132
|
+
}
|
|
155
133
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
134
|
+
// Handle form_post response (HTML with hidden inputs)
|
|
135
|
+
if (status === 200 && typeof response.data === 'string' && response.data.includes('<form')) {
|
|
136
|
+
const formMatch = response.data.match(/action="([^"]+)"/);
|
|
137
|
+
if (formMatch) {
|
|
138
|
+
const formAction = this.makeAbsoluteUrl(url, formMatch[1]);
|
|
139
|
+
const inputs = [...response.data.matchAll(/<input[^>]+>/g)];
|
|
140
|
+
const form = new URLSearchParams();
|
|
141
|
+
|
|
142
|
+
for (const input of inputs) {
|
|
143
|
+
const nameMatch = input[0].match(/name="([^"]+)"/);
|
|
144
|
+
const valueMatch = input[0].match(/value="([^"]*)"/);
|
|
145
|
+
if (nameMatch) {
|
|
146
|
+
form.append(nameMatch[1], valueMatch ? valueMatch[1] : '');
|
|
167
147
|
}
|
|
168
148
|
}
|
|
169
149
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
150
|
+
console.log('[OAuth] Submitting hidden form →', formAction);
|
|
151
|
+
return await this.followRedirects(formAction, {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
data: form.toString(),
|
|
154
|
+
headers: {
|
|
155
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
156
|
+
'Referer': url,
|
|
157
|
+
'User-Agent': this.MOBILE_USER_AGENT
|
|
158
|
+
}
|
|
159
|
+
}, depth + 1);
|
|
175
160
|
}
|
|
176
|
-
}
|
|
177
|
-
}
|
|
161
|
+
}
|
|
178
162
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
validateStatus: status => [200, 302, 303].includes(status)
|
|
186
|
-
});
|
|
163
|
+
// Handle redirect (302, 303)
|
|
164
|
+
if ([301, 302, 303].includes(status) && response.headers.location) {
|
|
165
|
+
const nextUrl = this.makeAbsoluteUrl(url, response.headers.location);
|
|
166
|
+
console.log('[OAuth] Following redirect →', nextUrl);
|
|
167
|
+
return await this.followRedirects(nextUrl, { method: 'GET' }, depth + 1);
|
|
168
|
+
}
|
|
187
169
|
|
|
188
|
-
|
|
189
|
-
if (
|
|
190
|
-
const match =
|
|
191
|
-
if (match)
|
|
170
|
+
// /Redirect pages may contain melcloudhome:// in body
|
|
171
|
+
if (url.includes('/Redirect') && typeof response.data === 'string') {
|
|
172
|
+
const match = response.data.match(/melcloudhome:\/\/[^"'\s<>]*/);
|
|
173
|
+
if (match) {
|
|
174
|
+
console.log('[OAuth] Found melcloudhome:// in HTML body');
|
|
175
|
+
return { url: match[0], data: response.data };
|
|
176
|
+
}
|
|
192
177
|
}
|
|
193
178
|
|
|
194
|
-
|
|
195
|
-
|
|
179
|
+
// No redirect, return final response
|
|
180
|
+
return { url, data: response.data };
|
|
196
181
|
}
|
|
197
182
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const tokens = tokenResponse.data;
|
|
216
|
-
this.emit('warn', `Token obtained: ${JSON.stringify(tokens)}`);
|
|
217
|
-
return tokens;
|
|
183
|
+
/**
|
|
184
|
+
* Generate PKCE code verifier/challenge
|
|
185
|
+
*/
|
|
186
|
+
generatePKCE() {
|
|
187
|
+
const verifier = crypto.randomBytes(32)
|
|
188
|
+
.toString('base64')
|
|
189
|
+
.replace(/\+/g, '-')
|
|
190
|
+
.replace(/\//g, '_')
|
|
191
|
+
.replace(/=/g, '');
|
|
192
|
+
const challenge = crypto.createHash('sha256')
|
|
193
|
+
.update(verifier)
|
|
194
|
+
.digest('base64')
|
|
195
|
+
.replace(/\+/g, '-')
|
|
196
|
+
.replace(/\//g, '_')
|
|
197
|
+
.replace(/=/g, '');
|
|
198
|
+
return { codeVerifier: verifier, codeChallenge: challenge };
|
|
199
|
+
}
|
|
218
200
|
|
|
219
|
-
|
|
220
|
-
|
|
201
|
+
/**
|
|
202
|
+
* Make absolute URL from relative one
|
|
203
|
+
*/
|
|
204
|
+
makeAbsoluteUrl(base, relative) {
|
|
205
|
+
if (relative.startsWith('http')) return relative;
|
|
206
|
+
const baseUrl = new URL(base);
|
|
207
|
+
if (relative.startsWith('/')) {
|
|
208
|
+
return `${baseUrl.origin}${relative}`;
|
|
221
209
|
}
|
|
210
|
+
return `${baseUrl.origin}${baseUrl.pathname.replace(/\/[^/]*$/, '/')}${relative}`;
|
|
222
211
|
}
|
|
223
212
|
}
|
|
224
213
|
|
|
214
|
+
|
|
225
215
|
export default MelCloudHomeToken;
|
|
226
216
|
|
|
227
217
|
|