homebridge-melcloud-control 4.0.0-beta.408 → 4.0.0-beta.409

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.0.0-beta.408",
4
+ "version": "4.0.0-beta.409",
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 connectToMelCloudHome() {
280
+ async connectToMelCloudHome1() {
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.login(this.user, this.passwd);
292
+ const { codeVerifier, url } = await melCloudHomeToken.buildAuthorizeUrl();
293
+ const code = await melCloudHomeToken.loginToMelCloudHome(url);
294
+ const token = await melCloudHomeToken.getTokens(code, codeVerifier);
295
295
 
296
- const accountInfo = { ContextKey: token, UseFahrenheit: false };
296
+ const accountInfo = { ContextKey: code, 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 connectToMelCloudHome1(refresh = false) {
305
+ async connectToMelCloudHome(refresh = false) {
306
306
  if (this.logDebug) this.emit('debug', `Connecting to MELCloud Home`);
307
307
 
308
308
  let browser;
@@ -1,210 +1,227 @@
1
1
  import axios from 'axios';
2
2
  import crypto from 'crypto';
3
- import { CookieJar } from 'tough-cookie';
4
3
  import { wrapper } from 'axios-cookiejar-support';
4
+ import { CookieJar } from 'tough-cookie';
5
+ import { JSDOM } from 'jsdom';
5
6
  import EventEmitter from 'events';
6
7
 
7
8
  const MOBILE_USER_AGENT = 'MonitorAndControl.App.Mobile/35 CFNetwork/3860.100.1 Darwin/25.0.0';
8
9
  const CLIENT_ID = 'homemobile';
9
10
  const REDIRECT_URI = 'melcloudhome://';
10
- const SCOPE = 'openid profile email';
11
- const AUTH_BASE = 'https://live-melcloudhome.auth.eu-west-1.amazoncognito.com';
12
- const TOKEN_URL = `${AUTH_BASE}/oauth2/token`;
13
- const AUTHORIZE_URL = `${AUTH_BASE}/login?`;
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
14
 
15
15
  class MelCloudHomeToken extends EventEmitter {
16
- constructor() {
16
+ constructor(config) {
17
17
  super();
18
- this.cookieJar = new CookieJar();
19
- this.http = wrapper(axios.create({ jar: this.cookieJar }));
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;
20
26
  }
21
27
 
22
- // --- PKCE ---
23
- _generatePkcePair() {
28
+ generatePKCE() {
24
29
  const verifier = crypto.randomBytes(32).toString('base64url');
25
- const challenge = crypto
26
- .createHash('sha256')
27
- .update(verifier)
28
- .digest('base64url');
30
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
29
31
  return { verifier, challenge };
30
32
  }
31
33
 
32
- // --- Extract Cognito hidden form fields ---
33
- _extractLoginForm(html) {
34
- if (typeof html !== 'string') return null;
35
-
36
- const state = html.match(/name=['"]state['"][^>]*value=['"]([^'"]+)['"]/i)?.[1];
37
- const clientId = html.match(/name=['"]client_id['"][^>]*value=['"]([^'"]+)['"]/i)?.[1];
38
- const redirectUri = html.match(/name=['"]redirect_uri['"][^>]*value=['"]([^'"]+)['"]/i)?.[1];
39
-
40
- if (!state || !clientId || !redirectUri) return null;
41
- return { state, clientId, redirectUri };
34
+ generateState() {
35
+ return crypto.randomBytes(32).toString('hex');
42
36
  }
43
37
 
44
- // --- GET helper with manual redirect handling ---
45
- async _get(url, depth = 0) {
46
- const cookies = await this.cookieJar.getCookieString(url);
47
- const resp = await this.http.get(url, {
48
- headers: {
49
- 'User-Agent': MOBILE_USER_AGENT,
50
- Accept:
51
- 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
52
- Cookie: cookies,
53
- },
54
- maxRedirects: 0,
55
- validateStatus: () => true,
56
- responseType: 'text',
57
- transformResponse: [(d) => d],
58
- });
59
-
60
- // Save cookies
61
- if (resp.headers['set-cookie']) {
62
- await Promise.all(
63
- resp.headers['set-cookie'].map((c) =>
64
- this.cookieJar.setCookie(c, url)
65
- )
66
- );
67
- }
38
+ async buildAuthorizeUrl() {
39
+ const pkce = this.generatePKCE();
40
+ const state = this.generateState();
68
41
 
69
- // Handle redirect
70
- if (
71
- resp.status >= 300 &&
72
- resp.status < 400 &&
73
- resp.headers.location &&
74
- depth < 5
75
- ) {
76
- const nextUrl = new URL(resp.headers.location, url).href;
77
- console.log(`[OAuth] Redirect → ${nextUrl}`);
78
- return this._get(nextUrl, depth + 1);
79
- }
42
+ const authUrl = new URL(AUTHORIZE_ENDPOINT);
43
+ authUrl.searchParams.set('client_id', CLIENT_ID);
44
+ authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
45
+ authUrl.searchParams.set('response_type', 'code');
46
+ authUrl.searchParams.set('scope', SCOPE);
47
+ authUrl.searchParams.set('code_challenge', pkce.challenge);
48
+ authUrl.searchParams.set('code_challenge_method', 'S256');
49
+ authUrl.searchParams.set('state', state);
80
50
 
81
- return resp;
51
+ return { url: authUrl.toString(), codeVerifier: pkce.verifier };
82
52
  }
83
53
 
84
- // --- POST helper ---
85
- async _post(url, data, headers = {}) {
86
- const cookies = await this.cookieJar.getCookieString(url);
87
- const resp = await this.http.post(url, data, {
88
- headers: {
89
- 'User-Agent': MOBILE_USER_AGENT,
90
- 'Content-Type': 'application/x-www-form-urlencoded',
91
- Cookie: cookies,
92
- ...headers,
93
- },
94
- maxRedirects: 0,
95
- validateStatus: () => true,
96
- responseType: 'text',
97
- transformResponse: [(d) => d],
98
- });
99
-
100
- if (resp.headers['set-cookie']) {
101
- await Promise.all(
102
- resp.headers['set-cookie'].map((c) =>
103
- this.cookieJar.setCookie(c, url)
104
- )
105
- );
106
- }
107
-
108
- return resp;
109
- }
54
+ async loginToMelCloudHome(authUrl) {
55
+ try {
56
+ const getResp = await this.client.get(authUrl, { headers: { 'Accept': 'text/html' } });
57
+ const cookies = getResp.headers['set-cookie'] || [];
58
+ const dom = new JSDOM(getResp.data);
59
+ const csrf = dom.window.document.querySelector('input[name="_csrf"]')?.value;
60
+
61
+ if (!csrf) {
62
+ this.emit('warn', 'CSRF token not found');
63
+ return null;
64
+ }
65
+
66
+ const formData = new URLSearchParams({
67
+ _csrf: csrf,
68
+ username: this.user,
69
+ password: this.passwd
70
+ });
110
71
 
111
- // --- MAIN LOGIN FLOW ---
112
- async login(email, password) {
113
- const { verifier, challenge } = this._generatePkcePair();
114
-
115
- const authUrl =
116
- `${AUTH_BASE}/oauth2/authorize?` +
117
- new URLSearchParams({
118
- client_id: CLIENT_ID,
119
- redirect_uri: REDIRECT_URI,
120
- response_type: 'code',
121
- scope: SCOPE,
122
- code_challenge: challenge,
123
- code_challenge_method: 'S256',
72
+ const response = await this.client.post(authUrl, formData.toString(), {
73
+ 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',
77
+ 'Content-Type': 'application/x-www-form-urlencoded',
78
+ 'Content-Length': formData.toString().length,
79
+ 'Cookie': cookies.join('; '),
80
+ 'Origin': 'https://live-melcloudhome.auth.eu-west-1.amazoncognito.com',
81
+ 'Referer': authUrl
82
+ },
83
+ maxRedirects: 0,
84
+ validateStatus: status => [200, 302, 400].includes(status)
124
85
  });
125
86
 
126
- console.log('[OAuth] Opening login page...');
127
- let resp = await this._get(authUrl);
128
- if (resp.status >= 300 && resp.status < 400) {
129
- const redirectUrl = new URL(resp.headers.location, AUTH_BASE).href;
130
- console.log(`[OAuth] Redirected to: ${redirectUrl}`);
131
- resp = await this._get(redirectUrl);
132
- }
87
+ if (response.status === 400) {
88
+ this.emit('warn', `Login failed: ${response.data}`);
89
+ return null;
90
+ }
91
+
92
+ // Extract authorization code
93
+ const code = await this.extractCodeFromResponse(
94
+ response.data,
95
+ response.headers,
96
+ async (url) => {
97
+ const r = await this.client.get(url, {
98
+ maxRedirects: 0,
99
+ validateStatus: status => [200, 302, 303].includes(status)
100
+ });
101
+ return this.extractCodeFromResponse(r.data, r.headers, async u => this.extractCodeFromResponse(r.data, r.headers, u));
102
+ }
103
+ );
133
104
 
134
- // Extract login form fields
135
- const form = this._extractLoginForm(resp.data);
136
- if (!form) {
137
- console.error('[OAuth] ❌ Cannot find Cognito login form fields!');
138
- console.log(resp.data.substring(0, 500));
139
- throw new Error('Cannot find login form');
140
- }
105
+ if (code) this.emit('warn', `Authorization code obtained: ${code}`);
106
+ return code || null;
141
107
 
142
- console.log(`[OAuth] Found form state: ${form.state.substring(0, 12)}...`);
108
+ } catch (err) {
109
+ this.emit('warn', `loginToMelCloudHome error: ${err}`);
110
+ return null;
111
+ }
112
+ }
143
113
 
144
- const loginUrl = resp.request.res.responseUrl || `${AUTH_BASE}/login`;
145
- const formData = new URLSearchParams({
146
- state: form.state,
147
- client_id: form.clientId,
148
- redirect_uri: form.redirectUri,
149
- username: email,
150
- password: password,
114
+ async extractCodeFromResponse(data, headers, followRedirect) {
115
+ return new Promise(async (resolve, reject) => {
116
+ try {
117
+ const locationHeader = headers['location'] || headers['Location'];
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
+ }
128
+
129
+ // 2️⃣ form_post HTML
130
+ const formCodeMatch = data.match(/name="code"\s+value="([^"]+)"/);
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
+ }
147
+
148
+ // 3️⃣ JS redirect in body
149
+ const bodyCodeMatch = data.match(/melcloudhome:\/\/[^"'\s]*[?&]code=([^&"'\s]+)/);
150
+ if (bodyCodeMatch) {
151
+ this.emit('warn', `Found code in body: ${bodyCodeMatch[1]}`);
152
+ resolve(bodyCodeMatch[1]);
153
+ return;
154
+ }
155
+
156
+ // 4️⃣ Follow redirect
157
+ if (locationHeader && ['301', '302', '303'].includes(headers['status'] || '')) {
158
+ this.emit('warn', `Following redirect to ${locationHeader}`);
159
+ try {
160
+ const code = await followRedirect(locationHeader);
161
+ resolve(code);
162
+ return;
163
+ } catch (err) {
164
+ this.emit('warn', `Follow redirect failed: ${err}`);
165
+ reject(err);
166
+ return;
167
+ }
168
+ }
169
+
170
+ this.emit('warn', 'Authorization code not found in response');
171
+ reject(new Error('Authorization code not found'));
172
+ } catch (err) {
173
+ this.emit('warn', `extractCodeFromResponse error: ${err}`);
174
+ reject(err);
175
+ }
151
176
  });
177
+ }
152
178
 
153
- console.log('[OAuth] Submitting Cognito login...');
154
- const loginResp = await this._post(loginUrl, formData.toString());
155
-
156
- // Cognito redirects with ?code=
157
- if (loginResp.status >= 300 && loginResp.status < 400) {
158
- const redirectUrl = new URL(loginResp.headers.location, AUTH_BASE).href;
159
- console.log(`[OAuth] Login redirect ${redirectUrl}`);
160
-
161
- const codeMatch = redirectUrl.match(/[?&]code=([^&]+)/);
162
- if (!codeMatch) throw new Error('No code found after login redirect');
163
-
164
- const code = codeMatch[1];
165
- console.log(`[OAuth] ✅ Got authorization code: ${code}`);
179
+ async submitFormPost(actionUrl, code, state) {
180
+ const formData = new URLSearchParams({ code, state });
181
+ this.emit('warn', `Submitting form_post to ${actionUrl}`);
182
+ const res = await this.client.post(actionUrl, formData.toString(), {
183
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
184
+ maxRedirects: 0,
185
+ validateStatus: status => [200, 302, 303].includes(status)
186
+ });
166
187
 
167
- return this.exchangeToken(code, verifier);
188
+ const location = res.headers['location'];
189
+ if (location) {
190
+ const match = location.match(/[?&]code=([^&]+)/);
191
+ if (match) return match[1];
168
192
  }
169
193
 
170
- console.error('[OAuth] Unexpected login status:', loginResp.status);
171
- console.log(loginResp.data.substring(0, 400));
172
- throw new Error('Unexpected login response');
194
+ this.emit('warn', 'Code not found after form_post submission');
195
+ throw new Error('Code not found after form_post submission');
173
196
  }
174
197
 
175
- // --- Exchange code for token ---
176
- async exchangeToken(code, verifier) {
177
- console.log('[OAuth] Exchanging code for token...');
178
-
179
- const body = new URLSearchParams({
198
+ async getTokens(code, codeVerifier) {
199
+ const tokenData = new URLSearchParams({
180
200
  grant_type: 'authorization_code',
181
- client_id: CLIENT_ID,
201
+ code: code,
182
202
  redirect_uri: REDIRECT_URI,
183
- code,
184
- code_verifier: verifier,
203
+ client_id: CLIENT_ID,
204
+ code_verifier: codeVerifier
185
205
  });
186
206
 
187
- const resp = await this.http.post(TOKEN_URL, body.toString(), {
188
- headers: {
189
- 'User-Agent': MOBILE_USER_AGENT,
190
- 'Content-Type': 'application/x-www-form-urlencoded',
191
- },
192
- });
207
+ try {
208
+ const tokenResponse = await this.client.post(TOKEN_ENDPOINT, tokenData.toString(), {
209
+ headers: {
210
+ 'Content-Type': 'application/x-www-form-urlencoded',
211
+ 'Authorization': 'Basic aG9tZW1vYmlsZTo='
212
+ }
213
+ });
193
214
 
194
- if (resp.status !== 200) {
195
- console.error('[OAuth] Token exchange failed:', resp.status);
196
- console.log(resp.data);
197
- throw new Error('Token exchange failed');
198
- }
215
+ const tokens = tokenResponse.data;
216
+ this.emit('warn', `Token obtained: ${JSON.stringify(tokens)}`);
217
+ return tokens;
199
218
 
200
- console.log('[OAuth] Token received successfully');
201
- return resp.data;
219
+ } catch (err) {
220
+ throw new Error(`Failed to obtain OAuth token: ${err}`);
221
+ }
202
222
  }
203
223
  }
204
224
 
205
-
206
-
207
-
208
225
  export default MelCloudHomeToken;
209
226
 
210
227