homebridge-melcloud-control 4.0.0-beta.397 → 4.0.0-beta.399

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.397",
4
+ "version": "4.0.0-beta.399",
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 connectToMelCloudHome1() {
280
+ async connectToMelCloudHome() {
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.getTokens(code, codeVerifier);
292
+ //const { codeVerifier, url } = await melCloudHomeToken.buildAuthorizeUrl();
293
+ //const code = await melCloudHomeToken.loginToMelCloudHome(url);
294
+ const token = await melCloudHomeToken.getTokens(this.user, this.passwd);
295
295
 
296
- const accountInfo = { ContextKey: code, UseFahrenheit: false };
296
+ const accountInfo = { ContextKey: token, 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 connectToMelCloudHome(refresh = false) {
305
+ async connectToMelCloudHome1(refresh = false) {
306
306
  if (this.logDebug) this.emit('debug', `Connecting to MELCloud Home`);
307
307
 
308
308
  let browser;
@@ -1,227 +1,217 @@
1
1
  import axios from 'axios';
2
2
  import crypto from 'crypto';
3
- import { wrapper } from 'axios-cookiejar-support';
4
3
  import { CookieJar } from 'tough-cookie';
5
- import { JSDOM } from 'jsdom';
6
- import EventEmitter from 'events';
7
-
8
- const MOBILE_USER_AGENT = 'MonitorAndControl.App.Mobile/35 CFNetwork/3860.100.1 Darwin/25.0.0';
9
- const CLIENT_ID = 'homemobile';
10
- const REDIRECT_URI = 'melcloudhome://';
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
-
15
- class MelCloudHomeToken extends EventEmitter {
16
- constructor(config) {
17
- super();
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;
26
- }
27
-
28
- generatePKCE() {
29
- const verifier = crypto.randomBytes(32).toString('base64url');
30
- const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
31
- return { verifier, challenge };
32
- }
33
-
34
- generateState() {
35
- return crypto.randomBytes(32).toString('hex');
36
- }
37
-
38
- async buildAuthorizeUrl() {
39
- const pkce = this.generatePKCE();
40
- const state = this.generateState();
4
+ import { wrapper } from 'axios-cookiejar-support';
41
5
 
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);
6
+ class MelCloudHomeToken {
7
+ constructor() {
8
+ this.MOBILE_USER_AGENT = 'MonitorAndControl.App.Mobile/35 CFNetwork/3860.100.1 Darwin/25.0.0';
9
+ this.CLIENT_ID = 'homemobile';
10
+ this.REDIRECT_URI = 'melcloudhome://';
11
+ this.SCOPE = 'openid profile email offline_access IdentityServerApi';
12
+ this.AUTH_URL = 'https://auth.melcloudhome.com/connect/authorize';
13
+ this.TOKEN_URL = 'https://auth.melcloudhome.com/connect/token';
50
14
 
51
- return { url: authUrl.toString(), codeVerifier: pkce.verifier };
15
+ // Axios with cookie support
16
+ const jar = new CookieJar();
17
+ this.client = wrapper(axios.create({
18
+ jar,
19
+ withCredentials: true,
20
+ maxRedirects: 0, // we’ll handle redirects manually
21
+ validateStatus: null,
22
+ headers: {
23
+ 'User-Agent': this.MOBILE_USER_AGENT,
24
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
25
+ },
26
+ }));
52
27
  }
53
28
 
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
- }
29
+ /**
30
+ * Entry point: logs in and returns tokens
31
+ */
32
+ async getTokens(email, password) {
33
+ const { codeVerifier, codeChallenge } = this.generatePKCE();
34
+ const state = crypto.randomBytes(32).toString('hex');
35
+
36
+ // Step 1: Authorization URL
37
+ const authUrl = `${this.AUTH_URL}?client_id=${this.CLIENT_ID}` +
38
+ `&redirect_uri=${encodeURIComponent(this.REDIRECT_URI)}` +
39
+ `&response_type=code` +
40
+ `&scope=${encodeURIComponent(this.SCOPE)}` +
41
+ `&code_challenge=${codeChallenge}` +
42
+ `&code_challenge_method=S256` +
43
+ `&state=${state}`;
44
+
45
+ console.log('[OAuth] Step 1: GET login page');
46
+ const loginPage = await this.client.get(authUrl);
47
+ if (loginPage.status !== 200) {
48
+ throw new Error(`Unexpected status on login page: ${loginPage.status}`);
49
+ }
65
50
 
66
- const formData = new URLSearchParams({
67
- _csrf: csrf,
68
- username: this.user,
69
- password: this.passwd
70
- });
51
+ // Extract CSRF token
52
+ const csrfMatch = loginPage.data.match(/name="_csrf"\s+value="([^"]+)"/);
53
+ if (!csrfMatch) throw new Error('Could not find CSRF token');
54
+ const csrf = csrfMatch[1];
55
+ console.log('[OAuth] Found CSRF token');
56
+
57
+ // Step 2: POST credentials
58
+ const formData = new URLSearchParams({
59
+ _csrf: csrf,
60
+ username: email,
61
+ password: password
62
+ });
71
63
 
72
- const response = await this.client.post(authUrl, formData.toString(), {
64
+ console.log('[OAuth] Step 2: POST login credentials');
65
+ const loginResponse = await this.followRedirects(
66
+ loginPage.request.res.responseUrl,
67
+ {
68
+ method: 'POST',
69
+ data: formData.toString(),
73
70
  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',
71
+ 'User-Agent': this.MOBILE_USER_AGENT,
77
72
  'Content-Type': 'application/x-www-form-urlencoded',
78
- 'Content-Length': formData.toString().length,
79
- 'Cookie': cookies.join('; '),
80
73
  '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)
85
- });
86
-
87
- if (response.status === 400) {
88
- this.emit('warn', `Login failed: ${response.data}`);
89
- return null;
74
+ 'Referer': loginPage.request.res.responseUrl,
75
+ }
90
76
  }
77
+ );
91
78
 
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
- );
79
+ if (!loginResponse.url.startsWith('melcloudhome://')) {
80
+ console.log('[OAuth] No melcloudhome:// redirect found');
81
+ console.log('[OAuth] Final URL:', loginResponse.url);
82
+ throw new Error('OAuth flow failed - did not reach redirect URI');
83
+ }
84
+
85
+ const codeMatch = loginResponse.url.match(/[?&]code=([^&]+)/);
86
+ if (!codeMatch) throw new Error('No authorization code found');
87
+ const authCode = codeMatch[1];
88
+ console.log('[OAuth] Got authorization code');
104
89
 
105
- if (code) this.emit('warn', `Authorization code obtained: ${code}`);
106
- return code || null;
90
+ // Step 3: Exchange code for tokens
91
+ console.log('[OAuth] Step 3: Exchange code for tokens');
92
+ const tokenData = new URLSearchParams({
93
+ grant_type: 'authorization_code',
94
+ code: authCode,
95
+ redirect_uri: this.REDIRECT_URI,
96
+ client_id: this.CLIENT_ID,
97
+ code_verifier: codeVerifier
98
+ });
99
+
100
+ const tokenResponse = await this.client.post(this.TOKEN_URL, tokenData.toString(), {
101
+ headers: {
102
+ 'Content-Type': 'application/x-www-form-urlencoded',
103
+ 'Authorization': 'Basic aG9tZW1vYmlsZTo=', // homemobile:
104
+ }
105
+ });
107
106
 
108
- } catch (err) {
109
- this.emit('warn', `loginToMelCloudHome error: ${err}`);
110
- return null;
107
+ if (tokenResponse.status !== 200) {
108
+ console.error('[OAuth] Token error response:', tokenResponse.data);
109
+ throw new Error(`Token exchange failed (HTTP ${tokenResponse.status})`);
111
110
  }
111
+
112
+ console.log('[OAuth] ✓ Token exchange successful');
113
+ return {
114
+ refreshToken: tokenResponse.data.refresh_token,
115
+ accessToken: tokenResponse.data.access_token,
116
+ expiresIn: tokenResponse.data.expires_in,
117
+ };
112
118
  }
113
119
 
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
- }
120
+ /**
121
+ * Follow redirects manually (handles IdentityServer form_post, /Redirect, etc.)
122
+ */
123
+ async followRedirects(url, options = {}, depth = 0) {
124
+ if (depth > 10) throw new Error('Too many redirects');
128
125
 
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
- }
126
+ const response = await this.client.request({ url, ...options });
127
+ const status = response.status;
147
128
 
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
- }
129
+ // Check for melcloudhome://
130
+ if (response.headers.location?.startsWith('melcloudhome://')) {
131
+ return { url: response.headers.location, data: response.data };
132
+ }
155
133
 
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;
134
+ // Handle form_post response (HTML with hidden inputs)
135
+ if (status === 200 && typeof response.data === 'string' && response.data.includes('<form')) {
136
+ const formMatch = response.data.match(/action="([^"]+)"/);
137
+ if (formMatch) {
138
+ const formAction = this.makeAbsoluteUrl(url, formMatch[1]);
139
+ const inputs = [...response.data.matchAll(/<input[^>]+>/g)];
140
+ const form = new URLSearchParams();
141
+
142
+ for (const input of inputs) {
143
+ const nameMatch = input[0].match(/name="([^"]+)"/);
144
+ const valueMatch = input[0].match(/value="([^"]*)"/);
145
+ if (nameMatch) {
146
+ form.append(nameMatch[1], valueMatch ? valueMatch[1] : '');
167
147
  }
168
148
  }
169
149
 
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);
150
+ console.log('[OAuth] Submitting hidden form →', formAction);
151
+ return await this.followRedirects(formAction, {
152
+ method: 'POST',
153
+ data: form.toString(),
154
+ headers: {
155
+ 'Content-Type': 'application/x-www-form-urlencoded',
156
+ 'Referer': url,
157
+ 'User-Agent': this.MOBILE_USER_AGENT
158
+ }
159
+ }, depth + 1);
175
160
  }
176
- });
177
- }
161
+ }
178
162
 
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
- });
163
+ // Handle redirect (302, 303)
164
+ if ([301, 302, 303].includes(status) && response.headers.location) {
165
+ const nextUrl = this.makeAbsoluteUrl(url, response.headers.location);
166
+ console.log('[OAuth] Following redirect →', nextUrl);
167
+ return await this.followRedirects(nextUrl, { method: 'GET' }, depth + 1);
168
+ }
187
169
 
188
- const location = res.headers['location'];
189
- if (location) {
190
- const match = location.match(/[?&]code=([^&]+)/);
191
- if (match) return match[1];
170
+ // /Redirect pages may contain melcloudhome:// in body
171
+ if (url.includes('/Redirect') && typeof response.data === 'string') {
172
+ const match = response.data.match(/melcloudhome:\/\/[^"'\s<>]*/);
173
+ if (match) {
174
+ console.log('[OAuth] Found melcloudhome:// in HTML body');
175
+ return { url: match[0], data: response.data };
176
+ }
192
177
  }
193
178
 
194
- this.emit('warn', 'Code not found after form_post submission');
195
- throw new Error('Code not found after form_post submission');
179
+ // No redirect, return final response
180
+ return { url, data: response.data };
196
181
  }
197
182
 
198
- async getTokens(code, codeVerifier) {
199
- const tokenData = new URLSearchParams({
200
- grant_type: 'authorization_code',
201
- code: code,
202
- redirect_uri: REDIRECT_URI,
203
- client_id: CLIENT_ID,
204
- code_verifier: codeVerifier
205
- });
206
-
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
- });
214
-
215
- const tokens = tokenResponse.data;
216
- this.emit('warn', `Token obtained: ${JSON.stringify(tokens)}`);
217
- return tokens;
183
+ /**
184
+ * Generate PKCE code verifier/challenge
185
+ */
186
+ generatePKCE() {
187
+ const verifier = crypto.randomBytes(32)
188
+ .toString('base64')
189
+ .replace(/\+/g, '-')
190
+ .replace(/\//g, '_')
191
+ .replace(/=/g, '');
192
+ const challenge = crypto.createHash('sha256')
193
+ .update(verifier)
194
+ .digest('base64')
195
+ .replace(/\+/g, '-')
196
+ .replace(/\//g, '_')
197
+ .replace(/=/g, '');
198
+ return { codeVerifier: verifier, codeChallenge: challenge };
199
+ }
218
200
 
219
- } catch (err) {
220
- throw new Error(`Failed to obtain OAuth token: ${err}`);
201
+ /**
202
+ * Make absolute URL from relative one
203
+ */
204
+ makeAbsoluteUrl(base, relative) {
205
+ if (relative.startsWith('http')) return relative;
206
+ const baseUrl = new URL(base);
207
+ if (relative.startsWith('/')) {
208
+ return `${baseUrl.origin}${relative}`;
221
209
  }
210
+ return `${baseUrl.origin}${baseUrl.pathname.replace(/\/[^/]*$/, '/')}${relative}`;
222
211
  }
223
212
  }
224
213
 
214
+
225
215
  export default MelCloudHomeToken;
226
216
 
227
217