homebridge-melcloud-control 4.3.11-beta.12 → 4.3.11-beta.14
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 +5 -2
- package/src/melcloudhome.js +13 -15
- package/src/melcloudhomeauth.js +206 -72
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"displayName": "MELCloud Control",
|
|
3
3
|
"name": "homebridge-melcloud-control",
|
|
4
|
-
"version": "4.3.11-beta.
|
|
4
|
+
"version": "4.3.11-beta.14",
|
|
5
5
|
"description": "Homebridge plugin to control Mitsubishi Air Conditioner, Heat Pump and Energy Recovery Ventilation.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "grzegorz914",
|
|
@@ -40,7 +40,10 @@
|
|
|
40
40
|
"axios": "^1.13.2",
|
|
41
41
|
"express": "^5.2.1",
|
|
42
42
|
"puppeteer": "^24.32.0",
|
|
43
|
-
"ws": "^8.18.3"
|
|
43
|
+
"ws": "^8.18.3",
|
|
44
|
+
"axios-cookiejar-support": "^6.0.5",
|
|
45
|
+
"tough-cookie": "^6.0.0",
|
|
46
|
+
"jsdom": "^27.2.0"
|
|
44
47
|
},
|
|
45
48
|
"keywords": [
|
|
46
49
|
"homebridge",
|
package/src/melcloudhome.js
CHANGED
|
@@ -457,26 +457,24 @@ class MelCloudHome extends EventEmitter {
|
|
|
457
457
|
|
|
458
458
|
async connect() {
|
|
459
459
|
if (this.logDebug) this.emit('debug', 'Connecting to MELCloud Home');
|
|
460
|
+
const client = new MELCloudHomeAuth({ debug: true });
|
|
460
461
|
try {
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
await auth.login(this.user, this.passwd);
|
|
464
|
-
|
|
462
|
+
await client.login(this.user, this.passwd);
|
|
465
463
|
console.log("Logged in!");
|
|
466
464
|
|
|
467
|
-
const
|
|
465
|
+
const ctx = await client.getUserContext();
|
|
466
|
+
console.log("User context:", JSON.stringify(ctx, null, 2));
|
|
468
467
|
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
Accept: "application/json",
|
|
472
|
-
"x-csrf": "1",
|
|
473
|
-
referer: "https://melcloudhome.com/dashboard",
|
|
474
|
-
},
|
|
475
|
-
});
|
|
468
|
+
const devices = await client.getDevices();
|
|
469
|
+
console.log("Devices:", JSON.stringify(devices, null, 2));
|
|
476
470
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
471
|
+
// If you need a token-like object:
|
|
472
|
+
const tokens = await client.getAuthTokenCandidates();
|
|
473
|
+
console.log("Token candidates:", tokens);
|
|
474
|
+
} catch (err) {
|
|
475
|
+
console.error("Error:", err.message || err);
|
|
476
|
+
} finally {
|
|
477
|
+
await client.logout();
|
|
480
478
|
}
|
|
481
479
|
}
|
|
482
480
|
}
|
package/src/melcloudhomeauth.js
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
|
+
// MelcloudHomeClient.js
|
|
1
2
|
import axios from "axios";
|
|
2
3
|
import { wrapper } from "axios-cookiejar-support";
|
|
3
4
|
import { CookieJar } from "tough-cookie";
|
|
4
5
|
import { JSDOM } from "jsdom";
|
|
5
6
|
|
|
7
|
+
/**
|
|
8
|
+
* MELCloud Home client (login via AWS Cognito OAuth).
|
|
9
|
+
*
|
|
10
|
+
* Notes:
|
|
11
|
+
* - MELCloud Home uses cookie/session-based auth after the Cognito OAuth redirect chain.
|
|
12
|
+
* - There is not necessarily a single "Bearer token" like classic MELCloud; instead the client
|
|
13
|
+
* keeps cookies and must send x-csrf header (value "1") + referer for API calls.
|
|
14
|
+
*
|
|
15
|
+
* Use:
|
|
16
|
+
* const client = new MelcloudHomeClient({ debug: true });
|
|
17
|
+
* await client.login(email, password);
|
|
18
|
+
* const ctx = await client.getUserContext();
|
|
19
|
+
*/
|
|
20
|
+
|
|
6
21
|
const USER_AGENT =
|
|
7
22
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
|
8
23
|
|
|
@@ -10,13 +25,16 @@ const BASE_URL = "https://melcloudhome.com";
|
|
|
10
25
|
const COGNITO_BASE =
|
|
11
26
|
"https://live-melcloudhome.auth.eu-west-1.amazoncognito.com";
|
|
12
27
|
|
|
13
|
-
export class
|
|
14
|
-
constructor() {
|
|
28
|
+
export class MelcloudHomeClient {
|
|
29
|
+
constructor({ timeout = 30000, debug = false } = {}) {
|
|
30
|
+
this.debug = debug;
|
|
15
31
|
this.jar = new CookieJar();
|
|
16
|
-
|
|
32
|
+
|
|
33
|
+
this.axios = wrapper(
|
|
17
34
|
axios.create({
|
|
18
35
|
jar: this.jar,
|
|
19
|
-
|
|
36
|
+
withCredentials: true,
|
|
37
|
+
timeout,
|
|
20
38
|
maxRedirects: 10,
|
|
21
39
|
headers: {
|
|
22
40
|
"User-Agent": USER_AGENT,
|
|
@@ -34,42 +52,63 @@ export class MELCloudHomeAuth {
|
|
|
34
52
|
this.authenticated = false;
|
|
35
53
|
}
|
|
36
54
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
55
|
+
log(...args) {
|
|
56
|
+
if (this.debug) console.log("[MelcloudHomeClient]", ...args);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- Step 1: start login flow and get Cognito login page + CSRF token ---
|
|
40
60
|
async startLoginFlow() {
|
|
41
|
-
|
|
61
|
+
this.log("Starting login flow: GET /bff/login");
|
|
62
|
+
const resp = await this.axios.get(`${BASE_URL}/bff/login`, {
|
|
42
63
|
params: { returnUrl: "/dashboard" },
|
|
64
|
+
validateStatus: () => true,
|
|
43
65
|
});
|
|
44
66
|
|
|
45
|
-
|
|
67
|
+
// axios places final URL on resp.request?.res?.responseUrl or resp.request?.path for some envs
|
|
68
|
+
const finalUrl =
|
|
69
|
+
resp.request?.res?.responseUrl ||
|
|
70
|
+
resp.request?.res?.responseUrl ||
|
|
71
|
+
(resp.config && resp.config.url) ||
|
|
72
|
+
"";
|
|
73
|
+
|
|
74
|
+
this.log("Received final redirect URL:", finalUrl);
|
|
46
75
|
|
|
76
|
+
// We expect redirect to Cognito login (amazoncognito.com/*/login)
|
|
47
77
|
if (!finalUrl.includes("amazoncognito.com/login")) {
|
|
48
|
-
|
|
78
|
+
// Some environments might follow redirects into HTML that contains the redirect link
|
|
79
|
+
// Try parse HTML for a form that posts to cognito if finalUrl isn't cognito.
|
|
80
|
+
if (resp.data && typeof resp.data === "string" && resp.data.includes("amazoncognito.com")) {
|
|
81
|
+
this.log("Found amazoncognito reference in html; proceeding to parse HTML for csrf");
|
|
82
|
+
} else {
|
|
83
|
+
throw new Error(`Unexpected redirect during login flow. finalUrl=${finalUrl}`);
|
|
84
|
+
}
|
|
49
85
|
}
|
|
50
86
|
|
|
51
87
|
const html = resp.data;
|
|
52
88
|
const csrf = this.extractCsrf(html);
|
|
53
|
-
|
|
54
89
|
if (!csrf) {
|
|
55
|
-
|
|
90
|
+
// It's possible Cognito login page uses meta or JS to set token; try other heuristics
|
|
91
|
+
throw new Error("Failed to extract CSRF token from Cognito login page HTML");
|
|
56
92
|
}
|
|
57
93
|
|
|
58
|
-
|
|
94
|
+
// Derive loginUrl: if the finalUrl contains query string or state, use it; otherwise find cognito login action in HTML
|
|
95
|
+
const loginUrl = this.findCognitoLoginUrl(html) || finalUrl;
|
|
96
|
+
|
|
97
|
+
return { loginUrl, csrf };
|
|
59
98
|
}
|
|
60
99
|
|
|
61
|
-
//
|
|
62
|
-
// 2. Submit credentials to Cognito
|
|
63
|
-
//
|
|
100
|
+
// --- Step 2: submit credentials to Cognito ---
|
|
64
101
|
async submitCredentials(loginUrl, csrf, username, password) {
|
|
102
|
+
this.log("Submitting credentials to Cognito:", loginUrl);
|
|
103
|
+
|
|
65
104
|
const payload = new URLSearchParams({
|
|
66
105
|
_csrf: csrf,
|
|
67
106
|
username,
|
|
68
107
|
password,
|
|
69
|
-
cognitoAsfData: "",
|
|
108
|
+
cognitoAsfData: "", // placeholder; may be required if Cognito enforces advanced security
|
|
70
109
|
});
|
|
71
110
|
|
|
72
|
-
const resp = await this.
|
|
111
|
+
const resp = await this.axios.post(loginUrl, payload.toString(), {
|
|
73
112
|
headers: {
|
|
74
113
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
75
114
|
Origin: COGNITO_BASE,
|
|
@@ -79,102 +118,197 @@ export class MELCloudHomeAuth {
|
|
|
79
118
|
validateStatus: () => true,
|
|
80
119
|
});
|
|
81
120
|
|
|
82
|
-
const finalUrl =
|
|
121
|
+
const finalUrl =
|
|
122
|
+
resp.request?.res?.responseUrl || resp.config?.url || "unknown";
|
|
123
|
+
|
|
124
|
+
this.log("Post-login final URL:", finalUrl, "status:", resp.status);
|
|
83
125
|
|
|
84
|
-
|
|
85
|
-
|
|
126
|
+
// Common failure patterns
|
|
127
|
+
if (finalUrl.includes("/error") || resp.status >= 400) {
|
|
128
|
+
// try extract error message
|
|
129
|
+
const errMsg = this.extractErrorMessage(resp.data) || "Unknown error from Cognito";
|
|
130
|
+
throw new Error(`Authentication failed: ${errMsg}`);
|
|
86
131
|
}
|
|
87
132
|
|
|
133
|
+
// If still on Cognito domain -> bad credentials or MFA required
|
|
88
134
|
if (finalUrl.includes("amazoncognito.com")) {
|
|
89
|
-
throw new Error("Authentication failed: invalid username or
|
|
135
|
+
throw new Error("Authentication failed: invalid username/password or additional challenge required");
|
|
90
136
|
}
|
|
91
137
|
|
|
92
|
-
// Success: redirected back to melcloudhome
|
|
138
|
+
// Success: redirected back to melcloudhome domain
|
|
93
139
|
if (finalUrl.includes("melcloudhome.com")) {
|
|
94
140
|
this.authenticated = true;
|
|
141
|
+
this.log("Successfully authenticated, session cookies stored.");
|
|
142
|
+
// Wait a short time for Blazor / client initialization (server may set additional cookies)
|
|
143
|
+
await this._delay(2500);
|
|
95
144
|
return true;
|
|
96
145
|
}
|
|
97
146
|
|
|
98
|
-
throw new Error(`Unexpected
|
|
147
|
+
throw new Error(`Unexpected redirect after login: ${finalUrl}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Public login method
|
|
151
|
+
async login(username, password) {
|
|
152
|
+
try {
|
|
153
|
+
const { loginUrl, csrf } = await this.startLoginFlow();
|
|
154
|
+
await this.submitCredentials(loginUrl, csrf, username, password);
|
|
155
|
+
|
|
156
|
+
// Validate session by calling /api/user/context
|
|
157
|
+
const ok = await this.checkSession();
|
|
158
|
+
if (!ok) {
|
|
159
|
+
this.authenticated = false;
|
|
160
|
+
throw new Error("Session not valid after login");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
this.log("Login flow complete and session validated.");
|
|
164
|
+
return true;
|
|
165
|
+
} catch (err) {
|
|
166
|
+
// Re-throw with contextual message
|
|
167
|
+
throw new Error(`Login error: ${err.message || err}`);
|
|
168
|
+
}
|
|
99
169
|
}
|
|
100
170
|
|
|
101
|
-
//
|
|
102
|
-
// 3. Validate session – equivalent to Python check_session()
|
|
103
|
-
//
|
|
171
|
+
// Check session by calling /api/user/context
|
|
104
172
|
async checkSession() {
|
|
105
173
|
if (!this.authenticated) return false;
|
|
106
|
-
|
|
174
|
+
this.log("Checking session via /api/user/context");
|
|
107
175
|
try {
|
|
108
|
-
const resp = await this.
|
|
176
|
+
const resp = await this.axios.get(`${BASE_URL}/api/user/context`, {
|
|
109
177
|
headers: {
|
|
110
178
|
Accept: "application/json",
|
|
111
179
|
"x-csrf": "1",
|
|
112
|
-
|
|
180
|
+
Referer: `${BASE_URL}/dashboard`,
|
|
113
181
|
},
|
|
114
182
|
validateStatus: () => true,
|
|
115
183
|
});
|
|
116
184
|
|
|
185
|
+
this.log("Context status:", resp.status);
|
|
117
186
|
if (resp.status === 200) return true;
|
|
118
|
-
if (resp.status === 401)
|
|
119
|
-
|
|
187
|
+
if (resp.status === 401) {
|
|
188
|
+
this.authenticated = false;
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
120
191
|
return false;
|
|
121
|
-
} catch {
|
|
192
|
+
} catch (err) {
|
|
193
|
+
this.log("checkSession error:", err && err.message);
|
|
122
194
|
return false;
|
|
123
195
|
}
|
|
124
196
|
}
|
|
125
197
|
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
198
|
+
// Get user context (returns JSON from /api/user/context)
|
|
199
|
+
async getUserContext() {
|
|
200
|
+
if (!this.authenticated) throw new Error("Not authenticated - call login()");
|
|
201
|
+
const resp = await this.axios.get(`${BASE_URL}/api/user/context`, {
|
|
202
|
+
headers: {
|
|
203
|
+
Accept: "application/json",
|
|
204
|
+
"x-csrf": "1",
|
|
205
|
+
Referer: `${BASE_URL}/dashboard`,
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
return resp.data;
|
|
209
|
+
}
|
|
131
210
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
211
|
+
// Example: get devices (may vary depending on API surface)
|
|
212
|
+
async getDevices() {
|
|
213
|
+
if (!this.authenticated) throw new Error("Not authenticated - call login()");
|
|
214
|
+
const resp = await this.axios.get(`${BASE_URL}/api/devices/devices`, {
|
|
215
|
+
headers: {
|
|
216
|
+
Accept: "application/json",
|
|
217
|
+
"x-csrf": "1",
|
|
218
|
+
Referer: `${BASE_URL}/dashboard`,
|
|
219
|
+
},
|
|
220
|
+
validateStatus: () => true,
|
|
221
|
+
});
|
|
138
222
|
|
|
139
|
-
|
|
140
|
-
|
|
223
|
+
if (resp.status !== 200) {
|
|
224
|
+
throw new Error(`Failed to get devices: ${resp.status}`);
|
|
225
|
+
}
|
|
226
|
+
return resp.data;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Logout
|
|
230
|
+
async logout() {
|
|
231
|
+
try {
|
|
232
|
+
await this.axios.get(`${BASE_URL}/bff/logout`, { validateStatus: () => true });
|
|
233
|
+
} catch (e) {
|
|
234
|
+
// ignore
|
|
235
|
+
} finally {
|
|
236
|
+
this.authenticated = false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
141
239
|
|
|
142
|
-
|
|
143
|
-
|
|
240
|
+
// Try to extract 'token-like' values:
|
|
241
|
+
// - Some flows may expose tokens in /api/user/context (accessToken/idToken)
|
|
242
|
+
// - other times, session is cookie-based only; return cookie-based identifiers if present.
|
|
243
|
+
async getAuthTokenCandidates() {
|
|
244
|
+
// prefer context
|
|
245
|
+
try {
|
|
246
|
+
const ctx = await this.getUserContext();
|
|
247
|
+
// return common token fields if present
|
|
248
|
+
const maybe = {};
|
|
249
|
+
if (ctx?.accessToken) maybe.accessToken = ctx.accessToken;
|
|
250
|
+
if (ctx?.idToken) maybe.idToken = ctx.idToken;
|
|
251
|
+
if (ctx?.refreshToken) maybe.refreshToken = ctx.refreshToken;
|
|
252
|
+
if (Object.keys(maybe).length) return maybe;
|
|
253
|
+
} catch (e) {
|
|
254
|
+
// ignore
|
|
255
|
+
}
|
|
144
256
|
|
|
145
|
-
|
|
257
|
+
// fallback: inspect cookies for Cognito identity / session cookies
|
|
258
|
+
const cookies = await this.jar.getCookies(BASE_URL);
|
|
259
|
+
const cookieMap = {};
|
|
260
|
+
for (const c of cookies) {
|
|
261
|
+
cookieMap[c.key] = c.value;
|
|
262
|
+
}
|
|
263
|
+
return { cookies: cookieMap };
|
|
146
264
|
}
|
|
147
265
|
|
|
148
|
-
//
|
|
149
|
-
// Extract CSRF from Cognito HTML
|
|
150
|
-
//
|
|
266
|
+
// Utility: extract CSRF token from HTML using JSDOM or regex fallback
|
|
151
267
|
extractCsrf(html) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
268
|
+
if (!html) return null;
|
|
269
|
+
try {
|
|
270
|
+
const dom = new JSDOM(html);
|
|
271
|
+
const el = dom.window.document.querySelector('input[name="_csrf"]');
|
|
272
|
+
if (el && el.value) return el.value;
|
|
273
|
+
} catch (e) {
|
|
274
|
+
// ignore and try regex
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// regex fallback
|
|
278
|
+
const m = html.match(/<input[^>]+name="_csrf"[^>]+value="([^"]+)"/i);
|
|
279
|
+
if (m) return m[1];
|
|
280
|
+
const m2 = html.match(/name="_csrf"[^>]*value='([^']+)'/i);
|
|
281
|
+
if (m2) return m2[1];
|
|
282
|
+
return null;
|
|
155
283
|
}
|
|
156
284
|
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
return this.client;
|
|
285
|
+
// Try to find Cognito login action URL inside HTML (form action) as fallback
|
|
286
|
+
findCognitoLoginUrl(html) {
|
|
287
|
+
if (!html) return null;
|
|
288
|
+
const m = html.match(/action="([^"]*amazoncognito\.com[^"]*)"/i);
|
|
289
|
+
if (m) return m[1];
|
|
290
|
+
return null;
|
|
165
291
|
}
|
|
166
292
|
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
293
|
+
// Try to extract an error message from Cognito error page HTML
|
|
294
|
+
extractErrorMessage(html) {
|
|
295
|
+
if (!html) return null;
|
|
296
|
+
const patterns = [
|
|
297
|
+
/<div[^>]*class="[^"]*error[^"]*"[^>]*>([^<]+)<\/div>/i,
|
|
298
|
+
/<span[^>]*class="[^"]*error[^"]*"[^>]*>([^<]+)<\/span>/i,
|
|
299
|
+
/<p[^>]*class="[^"]*error[^"]*"[^>]*>([^<]+)<\/p>/i,
|
|
300
|
+
/<div[^>]*id=".*error.*"[^>]*>([^<]+)<\/div>/i,
|
|
301
|
+
];
|
|
302
|
+
for (const p of patterns) {
|
|
303
|
+
const m = html.match(p);
|
|
304
|
+
if (m) return m[1].trim();
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
174
308
|
|
|
175
|
-
|
|
309
|
+
_delay(ms) {
|
|
310
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
176
311
|
}
|
|
177
312
|
}
|
|
178
313
|
|
|
179
|
-
export default MELCloudHomeAuth;
|
|
180
314
|
|