homebridge-melcloud-control 4.3.11-beta.19 → 4.3.11-beta.20

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.19",
4
+ "version": "4.3.11-beta.20",
5
5
  "description": "Homebridge plugin to control Mitsubishi Air Conditioner, Heat Pump and Energy Recovery Ventilation.",
6
6
  "license": "MIT",
7
7
  "author": "grzegorz914",
@@ -3,322 +3,226 @@ import { wrapper } from "axios-cookiejar-support";
3
3
  import { CookieJar } from "tough-cookie";
4
4
  import { JSDOM } from "jsdom";
5
5
 
6
- /**
7
- * MELCloud Home client (login via AWS Cognito OAuth).
8
- *
9
- * Notes:
10
- * - MELCloud Home uses cookie/session-based auth after the Cognito OAuth redirect chain.
11
- * - There is not necessarily a single "Bearer token" like classic MELCloud; instead the client
12
- * keeps cookies and must send x-csrf header (value "1") + referer for API calls.
13
- *
14
- * Use:
15
- * const client = new MelcloudHomeClient({ debug: true });
16
- * await client.login(email, password);
17
- * const ctx = await client.getUserContext();
18
- */
19
-
6
+ // Constants
20
7
  const USER_AGENT =
21
- "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
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
22
9
 
23
10
  const BASE_URL = "https://melcloudhome.com";
24
11
  const COGNITO_BASE =
25
- "https://live-melcloudhome.auth.eu-west-1.amazoncognito.com";
12
+ "https://live-melcloudhome.auth.eu-west-1.amazoncognito.com";
26
13
 
27
14
  class MELCloudHomeAuth {
28
- constructor({ timeout = 30000, debug = false } = {}) {
29
- this.debug = debug;
30
- this.jar = new CookieJar();
31
-
32
- this.axios = wrapper(
33
- axios.create({
34
- jar: this.jar,
35
- withCredentials: true,
36
- timeout,
37
- maxRedirects: 10,
38
- headers: {
39
- "User-Agent": USER_AGENT,
40
- Accept:
41
- "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
42
- "Accept-Language": "en-US,en;q=0.9",
43
- "sec-ch-ua":
44
- '"Chromium";v="124", "Google Chrome";v="124", "Not A(Brand";v="99"',
45
- "sec-ch-ua-mobile": "?0",
46
- "sec-ch-ua-platform": '"macOS"',
47
- },
48
- })
49
- );
50
-
51
- this.authenticated = false;
52
- }
53
-
54
- log(...args) {
55
- if (this.debug) console.log("[MelcloudHomeClient]", ...args);
56
- }
57
-
58
- // --- Step 1: start login flow and get Cognito login page + CSRF token ---
59
- async startLoginFlow() {
60
- this.log("Starting login flow: GET /bff/login");
61
- const resp = await this.axios.get(`${BASE_URL}/bff/login`, {
62
- params: { returnUrl: "/dashboard" },
63
- validateStatus: () => true,
64
- });
65
-
66
- // axios places final URL on resp.request?.res?.responseUrl or resp.request?.path for some envs
67
- const finalUrl =
68
- resp.request?.res?.responseUrl ||
69
- resp.request?.res?.responseUrl ||
70
- (resp.config && resp.config.url) ||
71
- "";
72
-
73
- this.log("Received final redirect URL:", finalUrl);
74
-
75
- // We expect redirect to Cognito login (amazoncognito.com/*/login)
76
- if (!finalUrl.includes("amazoncognito.com/login")) {
77
- // Some environments might follow redirects into HTML that contains the redirect link
78
- // Try parse HTML for a form that posts to cognito if finalUrl isn't cognito.
79
- if (resp.data && typeof resp.data === "string" && resp.data.includes("amazoncognito.com")) {
80
- this.log("Found amazoncognito reference in html; proceeding to parse HTML for csrf");
81
- } else {
82
- throw new Error(`Unexpected redirect during login flow. finalUrl=${finalUrl}`);
83
- }
84
- }
85
-
86
- const html = resp.data;
87
- const csrf = this.extractCsrf(html);
88
- if (!csrf) {
89
- // It's possible Cognito login page uses meta or JS to set token; try other heuristics
90
- throw new Error("Failed to extract CSRF token from Cognito login page HTML");
91
- }
92
-
93
- // Derive loginUrl: if the finalUrl contains query string or state, use it; otherwise find cognito login action in HTML
94
- const loginUrl = this.findCognitoLoginUrl(html) || finalUrl;
95
-
96
- return { loginUrl, csrf };
97
- }
98
-
99
- // --- Step 2: submit credentials to Cognito ---
100
- async submitCredentials(loginUrl, csrf, username, password) {
101
- this.log("Submitting credentials to Cognito:", loginUrl);
102
-
103
- const payload = new URLSearchParams({
104
- _csrf: csrf,
105
- username,
106
- password,
107
- cognitoAsfData: "",
108
- });
109
-
110
- const resp = await this.axios.post(loginUrl, payload.toString(), {
111
- headers: {
112
- "Content-Type": "application/x-www-form-urlencoded",
113
- Origin: COGNITO_BASE,
114
- Referer: loginUrl,
115
- },
116
- validateStatus: () => true,
117
- maxRedirects: 0
118
- });
119
-
120
- const redirectUrl =
121
- resp.headers.location ||
122
- resp.request?.res?.responseUrl ||
123
- resp.config.url;
124
-
125
- if (!redirectUrl.includes("signin-oidc")) {
126
- throw new Error("Authentication failed: signin-oidc callback not received");
127
- }
128
-
129
- this.log("Calling signin-oidc:", redirectUrl);
130
-
131
- // --- MEGA WAŻNE: wywołać callback ---
132
- const callback = await this.axios.get(redirectUrl, {
133
- headers: {
134
- Referer: loginUrl,
135
- "User-Agent": USER_AGENT,
136
- Accept: "text/html",
137
- },
138
- validateStatus: () => true
139
- });
140
-
141
- this.log("signin-oidc status:", callback.status);
142
-
143
- // Status 500 JEST NORMALNY – liczą się cookies!
144
- const cookies = await this.jar.getCookies(redirectUrl);
145
- this.log("After signin-oidc cookies:", cookies.map(c => c.key));
146
-
147
- // Jeżeli pojawił się cookie `meu.identity` – logowanie jest zakończone
148
- const hasIdentity = cookies.some(c => c.key.includes("meu.identity"));
149
- if (!hasIdentity) {
150
- throw new Error("openid callback did not produce a session cookie");
151
- }
152
-
153
- this.authenticated = true;
154
- return true;
155
- }
156
-
157
-
158
- // Public login method
159
- async login(username, password) {
160
- try {
161
- const { loginUrl, csrf } = await this.startLoginFlow();
162
- await this.submitCredentials(loginUrl, csrf, username, password);
163
-
164
- // Validate session by calling /api/user/context
165
- const ok = await this.checkSession();
166
- if (!ok) {
167
- this.authenticated = false;
168
- throw new Error("Session not valid after login");
169
- }
170
-
171
- this.log("Login flow complete and session validated.");
172
- return true;
173
- } catch (err) {
174
- // Re-throw with contextual message
175
- throw new Error(`Login error: ${err.message || err}`);
176
- }
15
+ constructor({ debug = false } = {}) {
16
+ this.debug = debug;
17
+ this.jar = new CookieJar();
18
+ this.axios = wrapper(
19
+ axios.create({
20
+ jar: this.jar,
21
+ withCredentials: true,
22
+ maxRedirects: 10,
23
+ headers: {
24
+ "User-Agent": USER_AGENT,
25
+ Accept:
26
+ "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
27
+ "Accept-Language": "en-US,en;q=0.9",
28
+ },
29
+ })
30
+ );
31
+ this.authenticated = false;
32
+ }
33
+
34
+ log(...args) {
35
+ if (this.debug) console.log("[MelcloudHomeClient]", ...args);
36
+ }
37
+
38
+ _delay(ms) {
39
+ return new Promise((r) => setTimeout(r, ms));
40
+ }
41
+
42
+ // --- Helper: generate cognitoAsfData (simplified) ---
43
+ generateAsfData() {
44
+ const data = {
45
+ deviceName: "Chrome",
46
+ osName: "Macintosh",
47
+ osVersion: "14_5",
48
+ timezoneOffset: new Date().getTimezoneOffset(),
49
+ userAgent: USER_AGENT,
50
+ screen: { width: 1440, height: 900 },
51
+ language: "en-US",
52
+ plugins: [],
53
+ fonts: [],
54
+ canvasFingerprint: "simplified", // placeholder
55
+ };
56
+ const json = JSON.stringify(data);
57
+ return Buffer.from(json).toString("base64");
58
+ }
59
+
60
+ // --- Extract CSRF token from HTML ---
61
+ extractCsrf(html) {
62
+ if (!html) return null;
63
+ try {
64
+ const dom = new JSDOM(html);
65
+ const el = dom.window.document.querySelector('input[name="_csrf"]');
66
+ if (el && el.value) return el.value;
67
+ } catch {}
68
+ const m = html.match(/<input[^>]+name="_csrf"[^>]+value="([^"]+)"/i);
69
+ if (m) return m[1];
70
+ return null;
71
+ }
72
+
73
+ findCognitoLoginUrl(html) {
74
+ if (!html) return null;
75
+ const m = html.match(/action="([^"]*amazoncognito\.com[^"]*)"/i);
76
+ if (m) return m[1];
77
+ return null;
78
+ }
79
+
80
+ extractErrorMessage(html) {
81
+ if (!html) return null;
82
+ const patterns = [
83
+ /<div[^>]*class="[^"]*error[^"]*"[^>]*>([^<]+)<\/div>/i,
84
+ /<span[^>]*class="[^"]*error[^"]*"[^>]*>([^<]+)<\/span>/i,
85
+ /<p[^>]*class="[^"]*error[^"]*"[^>]*>([^<]+)<\/p>/i,
86
+ ];
87
+ for (const p of patterns) {
88
+ const m = html.match(p);
89
+ if (m) return m[1].trim();
177
90
  }
178
-
179
- // Check session by calling /api/user/context
180
- async checkSession() {
181
- if (!this.authenticated) return false;
182
- this.log("Checking session via /api/user/context");
183
- try {
184
- const resp = await this.axios.get(`${BASE_URL}/api/user/context`, {
185
- headers: {
186
- Accept: "application/json",
187
- "x-csrf": "1",
188
- Referer: `${BASE_URL}/dashboard`,
189
- },
190
- validateStatus: () => true,
191
- });
192
-
193
- this.log("Context status:", resp.status);
194
- if (resp.status === 200) return true;
195
- if (resp.status === 401) {
196
- this.authenticated = false;
197
- return false;
198
- }
199
- return false;
200
- } catch (err) {
201
- this.log("checkSession error:", err && err.message);
202
- return false;
203
- }
91
+ return null;
92
+ }
93
+
94
+ async startLoginFlow() {
95
+ this.log("Starting login flow: GET /bff/login");
96
+ const resp = await this.axios.get(`${BASE_URL}/bff/login`, {
97
+ params: { returnUrl: "/dashboard" },
98
+ validateStatus: () => true,
99
+ });
100
+
101
+ const finalUrl =
102
+ resp.request?.res?.responseUrl || resp.config?.url || "";
103
+
104
+ this.log("Received final redirect URL:", finalUrl);
105
+
106
+ const csrf = this.extractCsrf(resp.data);
107
+ if (!csrf) throw new Error("Failed to extract CSRF token");
108
+
109
+ const loginUrl = this.findCognitoLoginUrl(resp.data) || finalUrl;
110
+ return { loginUrl, csrf };
111
+ }
112
+
113
+ async submitCredentials(loginUrl, csrf, username, password) {
114
+ this.log("Submitting credentials to Cognito...");
115
+
116
+ const payload = new URLSearchParams({
117
+ _csrf: csrf,
118
+ username,
119
+ password,
120
+ cognitoAsfData: this.generateAsfData(),
121
+ });
122
+
123
+ // Step 1: POST credentials
124
+ const resp = await this.axios.post(loginUrl, payload.toString(), {
125
+ headers: {
126
+ "Content-Type": "application/x-www-form-urlencoded",
127
+ Origin: COGNITO_BASE,
128
+ Referer: loginUrl,
129
+ },
130
+ validateStatus: () => true,
131
+ maxRedirects: 0,
132
+ });
133
+
134
+ const redirectUrl = resp.headers.location || resp.request?.res?.responseUrl;
135
+ if (!redirectUrl.includes("signin-oidc")) {
136
+ throw new Error(
137
+ "Authentication failed: signin-oidc callback not received"
138
+ );
204
139
  }
205
140
 
206
- // Get user context (returns JSON from /api/user/context)
207
- async getUserContext() {
208
- if (!this.authenticated) throw new Error("Not authenticated - call login()");
209
- const resp = await this.axios.get(`${BASE_URL}/api/user/context`, {
210
- headers: {
211
- Accept: "application/json",
212
- "x-csrf": "1",
213
- Referer: `${BASE_URL}/dashboard`,
214
- },
215
- });
216
- return resp.data;
141
+ this.log("Calling signin-oidc callback:", redirectUrl);
142
+
143
+ // Step 2: GET callback to set cookies
144
+ const callback = await this.axios.get(redirectUrl, {
145
+ headers: {
146
+ Referer: loginUrl,
147
+ "User-Agent": USER_AGENT,
148
+ Accept: "text/html",
149
+ },
150
+ validateStatus: () => true,
151
+ });
152
+
153
+ this.log("signin-oidc status:", callback.status);
154
+
155
+ // Check if cookie meu.identity is set
156
+ const cookies = await this.jar.getCookies(BASE_URL);
157
+ const hasIdentity = cookies.some((c) => c.key.includes("meu.identity"));
158
+ if (!hasIdentity) {
159
+ throw new Error(
160
+ "OpenID callback did not produce session cookie (cognitoAsfData missing or rejected)"
161
+ );
217
162
  }
218
163
 
219
- // Example: get devices (may vary depending on API surface)
220
- async getDevices() {
221
- if (!this.authenticated) throw new Error("Not authenticated - call login()");
222
- const resp = await this.axios.get(`${BASE_URL}/api/devices/devices`, {
223
- headers: {
224
- Accept: "application/json",
225
- "x-csrf": "1",
226
- Referer: `${BASE_URL}/dashboard`,
227
- },
228
- validateStatus: () => true,
229
- });
230
-
231
- if (resp.status !== 200) {
232
- throw new Error(`Failed to get devices: ${resp.status}`);
233
- }
234
- return resp.data;
164
+ this.authenticated = true;
165
+ this.log("Login successful, session cookie meu.identity is present");
166
+ await this._delay(1000); // short wait for Blazor initialization
167
+ return true;
168
+ }
169
+
170
+ async login(username, password) {
171
+ const { loginUrl, csrf } = await this.startLoginFlow();
172
+ await this.submitCredentials(loginUrl, csrf, username, password);
173
+
174
+ const valid = await this.checkSession();
175
+ if (!valid) throw new Error("Session not valid after login");
176
+
177
+ this.log("Login flow complete");
178
+ return true;
179
+ }
180
+
181
+ async checkSession() {
182
+ if (!this.authenticated) return false;
183
+ try {
184
+ const resp = await this.axios.get(`${BASE_URL}/api/user/context`, {
185
+ headers: {
186
+ Accept: "application/json",
187
+ "x-csrf": "1",
188
+ Referer: `${BASE_URL}/dashboard`,
189
+ },
190
+ validateStatus: () => true,
191
+ });
192
+ return resp.status === 200;
193
+ } catch {
194
+ return false;
235
195
  }
236
-
237
- // Logout
238
- async logout() {
239
- try {
240
- await this.axios.get(`${BASE_URL}/bff/logout`, { validateStatus: () => true });
241
- } catch (e) {
242
- // ignore
243
- } finally {
244
- this.authenticated = false;
245
- }
246
- }
247
-
248
- // Try to extract 'token-like' values:
249
- // - Some flows may expose tokens in /api/user/context (accessToken/idToken)
250
- // - other times, session is cookie-based only; return cookie-based identifiers if present.
251
- async getAuthTokenCandidates() {
252
- // prefer context
253
- try {
254
- const ctx = await this.getUserContext();
255
- // return common token fields if present
256
- const maybe = {};
257
- if (ctx?.accessToken) maybe.accessToken = ctx.accessToken;
258
- if (ctx?.idToken) maybe.idToken = ctx.idToken;
259
- if (ctx?.refreshToken) maybe.refreshToken = ctx.refreshToken;
260
- if (Object.keys(maybe).length) return maybe;
261
- } catch (e) {
262
- // ignore
263
- }
264
-
265
- // fallback: inspect cookies for Cognito identity / session cookies
266
- const cookies = await this.jar.getCookies(BASE_URL);
267
- const cookieMap = {};
268
- for (const c of cookies) {
269
- cookieMap[c.key] = c.value;
270
- }
271
- return { cookies: cookieMap };
272
- }
273
-
274
- // Utility: extract CSRF token from HTML using JSDOM or regex fallback
275
- extractCsrf(html) {
276
- if (!html) return null;
277
- try {
278
- const dom = new JSDOM(html);
279
- const el = dom.window.document.querySelector('input[name="_csrf"]');
280
- if (el && el.value) return el.value;
281
- } catch (e) {
282
- // ignore and try regex
283
- }
284
-
285
- // regex fallback
286
- const m = html.match(/<input[^>]+name="_csrf"[^>]+value="([^"]+)"/i);
287
- if (m) return m[1];
288
- const m2 = html.match(/name="_csrf"[^>]*value='([^']+)'/i);
289
- if (m2) return m2[1];
290
- return null;
291
- }
292
-
293
- // Try to find Cognito login action URL inside HTML (form action) as fallback
294
- findCognitoLoginUrl(html) {
295
- if (!html) return null;
296
- const m = html.match(/action="([^"]*amazoncognito\.com[^"]*)"/i);
297
- if (m) return m[1];
298
- return null;
299
- }
300
-
301
- // Try to extract an error message from Cognito error page HTML
302
- extractErrorMessage(html) {
303
- if (!html) return null;
304
- const patterns = [
305
- /<div[^>]*class="[^"]*error[^"]*"[^>]*>([^<]+)<\/div>/i,
306
- /<span[^>]*class="[^"]*error[^"]*"[^>]*>([^<]+)<\/span>/i,
307
- /<p[^>]*class="[^"]*error[^"]*"[^>]*>([^<]+)<\/p>/i,
308
- /<div[^>]*id=".*error.*"[^>]*>([^<]+)<\/div>/i,
309
- ];
310
- for (const p of patterns) {
311
- const m = html.match(p);
312
- if (m) return m[1].trim();
313
- }
314
- return null;
315
- }
316
-
317
- _delay(ms) {
318
- return new Promise((r) => setTimeout(r, ms));
196
+ }
197
+
198
+ async getUserContext() {
199
+ if (!this.authenticated) throw new Error("Not authenticated");
200
+ const resp = await this.axios.get(`${BASE_URL}/api/user/context`, {
201
+ headers: { Accept: "application/json", "x-csrf": "1", Referer: `${BASE_URL}/dashboard` },
202
+ });
203
+ return resp.data;
204
+ }
205
+
206
+ async getDevices() {
207
+ if (!this.authenticated) throw new Error("Not authenticated");
208
+ const resp = await this.axios.get(`${BASE_URL}/api/devices/devices`, {
209
+ headers: { Accept: "application/json", "x-csrf": "1", Referer: `${BASE_URL}/dashboard` },
210
+ validateStatus: () => true,
211
+ });
212
+ if (resp.status !== 200) throw new Error(`Failed to get devices: ${resp.status}`);
213
+ return resp.data;
214
+ }
215
+
216
+ async logout() {
217
+ try {
218
+ await this.axios.get(`${BASE_URL}/bff/logout`, { validateStatus: () => true });
219
+ } finally {
220
+ this.authenticated = false;
319
221
  }
222
+ }
320
223
  }
321
224
 
225
+
322
226
  export default MELCloudHomeAuth;
323
227
 
324
228