homebridge-melcloud-control 4.0.0-beta.403 → 4.0.0-beta.404
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 +159 -126
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.404",
|
|
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
|
@@ -4,180 +4,213 @@ import { CookieJar } from 'tough-cookie';
|
|
|
4
4
|
import { wrapper } from 'axios-cookiejar-support';
|
|
5
5
|
import EventEmitter from 'events';
|
|
6
6
|
|
|
7
|
-
const MOBILE_USER_AGENT =
|
|
7
|
+
const MOBILE_USER_AGENT =
|
|
8
|
+
'MonitorAndControl.App.Mobile/35 CFNetwork/3860.100.1 Darwin/25.0.0';
|
|
9
|
+
|
|
8
10
|
const CLIENT_ID = 'homemobile';
|
|
9
11
|
const REDIRECT_URI = 'melcloudhome://';
|
|
10
|
-
const SCOPE = 'openid profile';
|
|
12
|
+
const SCOPE = 'openid profile email';
|
|
13
|
+
const AUTH_BASE =
|
|
14
|
+
'https://live-melcloudhome.auth.eu-west-1.amazoncognito.com';
|
|
15
|
+
const TOKEN_URL = `${AUTH_BASE}/oauth2/token`;
|
|
16
|
+
const AUTHORIZE_URL = `${AUTH_BASE}/login?`;
|
|
11
17
|
|
|
12
18
|
class MelCloudHomeToken extends EventEmitter {
|
|
13
19
|
constructor() {
|
|
14
20
|
super();
|
|
15
21
|
this.cookieJar = new CookieJar();
|
|
22
|
+
this.http = wrapper(axios.create({ jar: this.cookieJar }));
|
|
16
23
|
}
|
|
17
24
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const html = await this._get(authUrl);
|
|
28
|
-
const csrf = this._extractCsrf(html);
|
|
29
|
-
if (!csrf) throw new Error('Cannot find CSRF token');
|
|
30
|
-
|
|
31
|
-
console.log('[OAuth] Submitting credentials...');
|
|
32
|
-
|
|
33
|
-
const form = new URLSearchParams({
|
|
34
|
-
_csrf: csrf,
|
|
35
|
-
username: email,
|
|
36
|
-
password: password,
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
const loginResp = await this._post('https://live-melcloudhome.auth.eu-west-1.amazoncognito.com/login', form);
|
|
40
|
-
|
|
41
|
-
if (loginResp.status === 302 && loginResp.headers.location) {
|
|
42
|
-
const nextUrl = this._resolveUrl('https://live-melcloudhome.auth.eu-west-1.amazoncognito.com', loginResp.headers.location);
|
|
43
|
-
console.log('[OAuth] Redirecting to:', nextUrl);
|
|
44
|
-
return await this._followRedirects(nextUrl, verifier);
|
|
45
|
-
} else if (loginResp.status === 200) {
|
|
46
|
-
throw new Error('Login failed — probably wrong credentials');
|
|
47
|
-
} else {
|
|
48
|
-
throw new Error(`Unexpected login status: ${loginResp.status}`);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async _followRedirects(url, verifier, depth = 0) {
|
|
53
|
-
if (depth > 10) throw new Error('Too many redirects');
|
|
54
|
-
|
|
55
|
-
const resp = await this._get(url);
|
|
56
|
-
|
|
57
|
-
if (resp.status === 302 && resp.headers.location) {
|
|
58
|
-
const nextUrl = this._resolveUrl(url, resp.headers.location);
|
|
59
|
-
console.log(`[OAuth] Redirect (${resp.status}) to: ${nextUrl}`);
|
|
60
|
-
return await this._followRedirects(nextUrl, verifier, depth + 1);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
if (typeof resp.data === 'string') {
|
|
64
|
-
const code = this._extractAuthCode(resp.data);
|
|
65
|
-
if (code) {
|
|
66
|
-
console.log('[OAuth] Got authorization code');
|
|
67
|
-
return await this._exchangeToken(code, verifier);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
throw new Error(`No authorization code found (status ${resp.status})`);
|
|
25
|
+
// --- Utility: generate PKCE pair ---
|
|
26
|
+
_generatePkcePair() {
|
|
27
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
28
|
+
const challenge = crypto
|
|
29
|
+
.createHash('sha256')
|
|
30
|
+
.update(verifier)
|
|
31
|
+
.digest('base64url');
|
|
32
|
+
return { verifier, challenge };
|
|
72
33
|
}
|
|
73
34
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
grant_type: 'authorization_code',
|
|
80
|
-
client_id: CLIENT_ID,
|
|
81
|
-
code: code,
|
|
82
|
-
redirect_uri: REDIRECT_URI,
|
|
83
|
-
code_verifier: verifier,
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
const resp = await axios.post(tokenUrl, data.toString(), {
|
|
87
|
-
headers: {
|
|
88
|
-
'User-Agent': MOBILE_USER_AGENT,
|
|
89
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
90
|
-
'Authorization': 'Basic aG9tZW1vYmlsZTo=',
|
|
91
|
-
},
|
|
92
|
-
validateStatus: () => true,
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
if (resp.status !== 200) {
|
|
96
|
-
console.error('[OAuth] Token exchange failed:', resp.status, resp.data);
|
|
97
|
-
throw new Error(`Token exchange failed with status ${resp.status}`);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
console.log('[OAuth] Tokens received!');
|
|
101
|
-
return {
|
|
102
|
-
accessToken: resp.data.access_token,
|
|
103
|
-
refreshToken: resp.data.refresh_token,
|
|
104
|
-
expiresIn: resp.data.expires_in,
|
|
105
|
-
};
|
|
35
|
+
// --- Utility: extract CSRF token from HTML ---
|
|
36
|
+
_extractCsrf(html) {
|
|
37
|
+
if (typeof html !== 'string') return null;
|
|
38
|
+
const match = html.match(/name=['"]_csrf['"][^>]*value=['"]([^'"]+)['"]/i);
|
|
39
|
+
return match ? match[1] : null;
|
|
106
40
|
}
|
|
107
41
|
|
|
108
|
-
|
|
42
|
+
// --- GET request with redirect + cookie management ---
|
|
43
|
+
async _get(url, depth = 0) {
|
|
109
44
|
const cookies = await this.cookieJar.getCookieString(url);
|
|
110
|
-
const resp = await
|
|
45
|
+
const resp = await this.http.get(url, {
|
|
111
46
|
headers: {
|
|
112
47
|
'User-Agent': MOBILE_USER_AGENT,
|
|
113
|
-
|
|
114
|
-
|
|
48
|
+
Accept:
|
|
49
|
+
'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
50
|
+
Cookie: cookies,
|
|
115
51
|
},
|
|
116
|
-
validateStatus: () => true,
|
|
117
52
|
maxRedirects: 0,
|
|
53
|
+
validateStatus: () => true,
|
|
118
54
|
responseType: 'text',
|
|
119
|
-
transformResponse: [(
|
|
55
|
+
transformResponse: [(d) => d],
|
|
120
56
|
});
|
|
57
|
+
|
|
58
|
+
// Save cookies
|
|
121
59
|
if (resp.headers['set-cookie']) {
|
|
122
|
-
await
|
|
60
|
+
await Promise.all(
|
|
61
|
+
resp.headers['set-cookie'].map((c) =>
|
|
62
|
+
this.cookieJar.setCookie(c, url)
|
|
63
|
+
)
|
|
64
|
+
);
|
|
123
65
|
}
|
|
66
|
+
|
|
67
|
+
// Follow redirect manually
|
|
68
|
+
if (
|
|
69
|
+
resp.status >= 300 &&
|
|
70
|
+
resp.status < 400 &&
|
|
71
|
+
resp.headers.location &&
|
|
72
|
+
depth < 5
|
|
73
|
+
) {
|
|
74
|
+
const nextUrl = new URL(resp.headers.location, url).href;
|
|
75
|
+
console.log(`[OAuth] Redirect → ${nextUrl}`);
|
|
76
|
+
return this._get(nextUrl, depth + 1);
|
|
77
|
+
}
|
|
78
|
+
|
|
124
79
|
return resp;
|
|
125
80
|
}
|
|
126
81
|
|
|
127
|
-
|
|
82
|
+
// --- POST request helper ---
|
|
83
|
+
async _post(url, data, headers = {}) {
|
|
128
84
|
const cookies = await this.cookieJar.getCookieString(url);
|
|
129
|
-
const resp = await
|
|
85
|
+
const resp = await this.http.post(url, data, {
|
|
130
86
|
headers: {
|
|
131
87
|
'User-Agent': MOBILE_USER_AGENT,
|
|
132
88
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
'Content-Length': Buffer.byteLength(formData.toString()),
|
|
89
|
+
Cookie: cookies,
|
|
90
|
+
...headers,
|
|
136
91
|
},
|
|
137
|
-
validateStatus: () => true,
|
|
138
92
|
maxRedirects: 0,
|
|
93
|
+
validateStatus: () => true,
|
|
139
94
|
responseType: 'text',
|
|
140
|
-
transformResponse: [(
|
|
95
|
+
transformResponse: [(d) => d],
|
|
141
96
|
});
|
|
97
|
+
|
|
142
98
|
if (resp.headers['set-cookie']) {
|
|
143
|
-
await
|
|
99
|
+
await Promise.all(
|
|
100
|
+
resp.headers['set-cookie'].map((c) =>
|
|
101
|
+
this.cookieJar.setCookie(c, url)
|
|
102
|
+
)
|
|
103
|
+
);
|
|
144
104
|
}
|
|
105
|
+
|
|
145
106
|
return resp;
|
|
146
107
|
}
|
|
147
108
|
|
|
109
|
+
// --- Main login flow ---
|
|
110
|
+
async login(email, password) {
|
|
111
|
+
const { verifier, challenge } = this._generatePkcePair();
|
|
112
|
+
|
|
113
|
+
// Build authorize URL
|
|
114
|
+
const authUrl =
|
|
115
|
+
`${AUTH_BASE}/oauth2/authorize?` +
|
|
116
|
+
new URLSearchParams({
|
|
117
|
+
client_id: CLIENT_ID,
|
|
118
|
+
redirect_uri: REDIRECT_URI,
|
|
119
|
+
response_type: 'code',
|
|
120
|
+
scope: SCOPE,
|
|
121
|
+
code_challenge: challenge,
|
|
122
|
+
code_challenge_method: 'S256',
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
console.log(`[OAuth] Opening login page...`);
|
|
126
|
+
let resp = await this._get(authUrl);
|
|
127
|
+
if (resp.status >= 300 && resp.status < 400) {
|
|
128
|
+
const redirectUrl = new URL(resp.headers.location, AUTH_BASE).href;
|
|
129
|
+
console.log(`[OAuth] Redirected to: ${redirectUrl}`);
|
|
130
|
+
resp = await this._get(redirectUrl);
|
|
131
|
+
}
|
|
148
132
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
133
|
+
// --- Extract CSRF token ---
|
|
134
|
+
const csrf = this._extractCsrf(resp.data);
|
|
135
|
+
if (!csrf) {
|
|
136
|
+
console.error('[OAuth] ❌ Cannot find CSRF token!');
|
|
137
|
+
console.log(resp.data.substring(0, 400));
|
|
138
|
+
throw new Error('Cannot find CSRF token');
|
|
139
|
+
}
|
|
154
140
|
|
|
155
|
-
|
|
156
|
-
if (typeof html !== 'string') return null;
|
|
157
|
-
const urlMatch = html.match(/[?&]code=([^&]+)/);
|
|
158
|
-
if (urlMatch) return urlMatch[1];
|
|
159
|
-
const formMatch = html.match(/name="code"\s+value="([^"]+)"/);
|
|
160
|
-
return formMatch ? formMatch[1] : null;
|
|
161
|
-
}
|
|
141
|
+
console.log(`[OAuth] Found CSRF token: ${csrf}`);
|
|
162
142
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
143
|
+
// --- Submit login form ---
|
|
144
|
+
const loginUrl = resp.request.res.responseUrl || `${AUTH_BASE}/login`;
|
|
145
|
+
const formData = new URLSearchParams({
|
|
146
|
+
_csrf: csrf,
|
|
147
|
+
username: email,
|
|
148
|
+
password: password,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
console.log(`[OAuth] Submitting login form...`);
|
|
152
|
+
const loginResp = await this._post(loginUrl, formData.toString());
|
|
166
153
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
154
|
+
if (loginResp.status >= 300 && loginResp.status < 400) {
|
|
155
|
+
const redirectUrl = new URL(loginResp.headers.location, AUTH_BASE).href;
|
|
156
|
+
console.log(`[OAuth] Login redirect → ${redirectUrl}`);
|
|
157
|
+
|
|
158
|
+
// Extract code from redirect URL
|
|
159
|
+
const codeMatch = redirectUrl.match(/[?&]code=([^&]+)/);
|
|
160
|
+
if (!codeMatch) throw new Error('No code found after login redirect');
|
|
161
|
+
const code = codeMatch[1];
|
|
162
|
+
console.log(`[OAuth] ✅ Got authorization code: ${code}`);
|
|
163
|
+
|
|
164
|
+
// Exchange code for token
|
|
165
|
+
return this.exchangeToken(code, verifier);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
console.error('[OAuth] ❌ Unexpected status on login:', loginResp.status);
|
|
169
|
+
console.log(loginResp.data.substring(0, 400));
|
|
170
|
+
throw new Error('Unexpected status on login page');
|
|
170
171
|
}
|
|
171
172
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
173
|
+
// --- Exchange code for access token ---
|
|
174
|
+
async exchangeToken(code, verifier) {
|
|
175
|
+
console.log('[OAuth] Exchanging code for token...');
|
|
176
|
+
|
|
177
|
+
const body = new URLSearchParams({
|
|
178
|
+
grant_type: 'authorization_code',
|
|
179
|
+
client_id: CLIENT_ID,
|
|
180
|
+
redirect_uri: REDIRECT_URI,
|
|
181
|
+
code,
|
|
182
|
+
code_verifier: verifier,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const resp = await this.http.post(TOKEN_URL, body.toString(), {
|
|
186
|
+
headers: {
|
|
187
|
+
'User-Agent': MOBILE_USER_AGENT,
|
|
188
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
if (resp.status !== 200) {
|
|
193
|
+
console.error('[OAuth] ❌ Token exchange failed:', resp.status);
|
|
194
|
+
console.log(resp.data);
|
|
195
|
+
throw new Error('Token exchange failed');
|
|
177
196
|
}
|
|
197
|
+
|
|
198
|
+
console.log('[OAuth] ✅ Token response received');
|
|
199
|
+
return resp.data;
|
|
178
200
|
}
|
|
179
201
|
}
|
|
180
202
|
|
|
203
|
+
// --- Example usage ---
|
|
204
|
+
(async () => {
|
|
205
|
+
try {
|
|
206
|
+
const mel = new MelCloudHomeOAuth();
|
|
207
|
+
const token = await mel.login('YOUR_EMAIL', 'YOUR_PASSWORD');
|
|
208
|
+
console.log('✅ Final token:', token);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
console.error('iDom Home, Connect error:', err.message);
|
|
211
|
+
}
|
|
212
|
+
})();
|
|
213
|
+
|
|
181
214
|
|
|
182
215
|
export default MelCloudHomeToken;
|
|
183
216
|
|