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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "displayName": "MELCloud Control",
3
3
  "name": "homebridge-melcloud-control",
4
- "version": "4.3.11-beta.12",
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",
@@ -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
- const auth = new MELCloudHomeAuth();
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 client = auth.getClient();
465
+ const ctx = await client.getUserContext();
466
+ console.log("User context:", JSON.stringify(ctx, null, 2));
468
467
 
469
- const ctx = await client.get("https://melcloudhome.com/api/user/context", {
470
- headers: {
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
- console.log(ctx.data);
478
- } catch (error) {
479
- throw new Error(`Connect error: ${error.message}`);
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
  }
@@ -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 MELCloudHomeAuth {
14
- constructor() {
28
+ export class MelcloudHomeClient {
29
+ constructor({ timeout = 30000, debug = false } = {}) {
30
+ this.debug = debug;
15
31
  this.jar = new CookieJar();
16
- this.client = wrapper(
32
+
33
+ this.axios = wrapper(
17
34
  axios.create({
18
35
  jar: this.jar,
19
- timeout: 30000,
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
- // 1. Start OAuth login → redirected to AWS Cognito login page
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
- const resp = await this.client.get(`${BASE_URL}/bff/login`, {
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
- const finalUrl = resp.request.res.responseUrl;
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
- throw new Error(`Unexpected redirect: ${finalUrl}`);
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
- throw new Error("Failed to extract CSRF token.");
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
- return { loginUrl: finalUrl, csrf };
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.client.post(loginUrl, payload, {
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 = resp.request.res.responseUrl;
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
- if (finalUrl.includes("/error")) {
85
- throw new Error("Authentication failed: error returned from Cognito");
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 password");
135
+ throw new Error("Authentication failed: invalid username/password or additional challenge required");
90
136
  }
91
137
 
92
- // Success: redirected back to melcloudhome.com
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 final redirect: ${finalUrl}`);
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.client.get(`${BASE_URL}/api/user/context`, {
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
- referer: `${BASE_URL}/dashboard`,
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) return false;
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
- // 4. Main login() method
128
- //
129
- async login(username, password) {
130
- const step1 = await this.startLoginFlow();
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
- await this.submitCredentials(
133
- step1.loginUrl,
134
- step1.csrf,
135
- username,
136
- password
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
- // Wait for Blazor session init – same as Python sleep(3)
140
- await new Promise((r) => setTimeout(r, 3000));
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
- const ok = await this.checkSession();
143
- if (!ok) throw new Error("Session not valid after login");
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
- return true;
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
- const dom = new JSDOM(html);
153
- const input = dom.window.document.querySelector('input[name="_csrf"]');
154
- return input?.value || null;
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
- // Return axios instance for authenticated calls
159
- //
160
- getClient() {
161
- if (!this.authenticated) {
162
- throw new Error("Not authenticated. Call login() first.");
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
- // Logout
169
- //
170
- async logout() {
171
- try {
172
- await this.client.get(`${BASE_URL}/bff/logout`);
173
- } catch (_) { }
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
- this.authenticated = false;
309
+ _delay(ms) {
310
+ return new Promise((r) => setTimeout(r, ms));
176
311
  }
177
312
  }
178
313
 
179
- export default MELCloudHomeAuth;
180
314