homebridge-melcloud-control 4.0.0-beta.396 → 4.0.0-beta.398

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.396",
4
+ "version": "4.0.0-beta.398",
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
@@ -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
@@ -1,126 +1,217 @@
1
+ import axios from 'axios';
1
2
  import crypto from 'crypto';
2
- import { wrapper } from 'axios-cookiejar-support';
3
3
  import { CookieJar } from 'tough-cookie';
4
- import axios from 'axios';
5
- import EventEmitter from 'events';
6
- import puppeteer from 'puppeteer';
7
-
8
- const CLIENT_ID = 'homemobile';
9
- const REDIRECT_URI = 'melcloudhome://';
10
- const SCOPE = 'openid profile email offline_access IdentityServerApi';
11
- const TOKEN_ENDPOINT = 'https://auth.melcloudhome.com/connect/token';
12
- const AUTHORIZE_ENDPOINT = 'https://auth.melcloudhome.com/connect/authorize';
13
- const MOBILE_USER_AGENT = 'MonitorAndControl.App.Mobile/35 CFNetwork/3860.100.1 Darwin/25.0.0';
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;
4
+ import { wrapper } from 'axios-cookiejar-support';
5
+
6
+ class MelCloudOAuth {
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';
14
+
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
+ }));
27
+ }
28
+
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}`);
26
49
  }
27
50
 
28
- generatePKCE() {
29
- const verifier = crypto.randomBytes(32).toString('base64url');
30
- const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
31
- return { verifier, challenge };
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
+ });
63
+
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(),
70
+ headers: {
71
+ 'User-Agent': this.MOBILE_USER_AGENT,
72
+ 'Content-Type': 'application/x-www-form-urlencoded',
73
+ 'Origin': 'https://live-melcloudhome.auth.eu-west-1.amazoncognito.com',
74
+ 'Referer': loginPage.request.res.responseUrl,
75
+ }
76
+ }
77
+ );
78
+
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');
32
83
  }
33
84
 
34
- generateState() {
35
- return crypto.randomBytes(32).toString('hex');
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');
89
+
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
+ });
106
+
107
+ if (tokenResponse.status !== 200) {
108
+ console.error('[OAuth] Token error response:', tokenResponse.data);
109
+ throw new Error(`Token exchange failed (HTTP ${tokenResponse.status})`);
36
110
  }
37
111
 
38
- async buildAuthorizeUrl() {
39
- const pkce = this.generatePKCE();
40
- const state = this.generateState();
41
- const authUrl = new URL(AUTHORIZE_ENDPOINT);
42
- authUrl.searchParams.set('client_id', CLIENT_ID);
43
- authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
44
- authUrl.searchParams.set('response_type', 'code');
45
- authUrl.searchParams.set('scope', SCOPE);
46
- authUrl.searchParams.set('code_challenge', pkce.challenge);
47
- authUrl.searchParams.set('code_challenge_method', 'S256');
48
- authUrl.searchParams.set('state', state);
49
- return { url: authUrl.toString(), codeVerifier: pkce.verifier };
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
+ };
118
+ }
119
+
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');
125
+
126
+ const response = await this.client.request({ url, ...options });
127
+ const status = response.status;
128
+
129
+ // Check for melcloudhome://
130
+ if (response.headers.location?.startsWith('melcloudhome://')) {
131
+ return { url: response.headers.location, data: response.data };
50
132
  }
51
133
 
52
- async loginToMelCloudHome(authUrl) {
53
- try {
54
- this.emit('warn', `Opening Puppeteer for auth URL`);
55
- const browser = await puppeteer.launch({ headless: true });
56
- const page = await browser.newPage();
57
- await page.setUserAgent(MOBILE_USER_AGENT);
58
-
59
- await page.goto(authUrl, { waitUntil: 'networkidle0' });
60
-
61
- // Wpisz login i hasło
62
- await page.type('input[name="username"]', this.user);
63
- await page.type('input[name="password"]', this.passwd);
64
-
65
- // Kliknij przycisk login
66
- await Promise.all([
67
- page.click('input[type="submit"]'),
68
- page.waitForNavigation({ waitUntil: 'networkidle0' }).catch(() => { }) // czasami redirect jest JS, więc catch
69
- ]);
70
-
71
- // Spróbuj pobrać kod z formularza form_post
72
- const code = await page.evaluate(() => {
73
- const form = document.querySelector('form[action^="melcloudhome://"]');
74
- if (form) {
75
- const input = form.querySelector('input[name="code"]');
76
- return input ? input.value : null;
77
- }
78
- return null;
79
- });
80
-
81
- await browser.close();
82
-
83
- if (code) {
84
- this.emit('warn', `Authorization code obtained from form_post: ${code}`);
85
- return code;
86
- } else {
87
- this.emit('warn', `Authorization code not found in Puppeteer`);
88
- return null;
89
- }
90
-
91
- } catch (err) {
92
- this.emit('warn', `loginToMelCloudHome error: ${err}`);
93
- return null;
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] : '');
147
+ }
94
148
  }
149
+
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);
160
+ }
95
161
  }
96
162
 
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
+ }
97
169
 
98
- async getTokens(code, codeVerifier) {
99
- const tokenData = new URLSearchParams({
100
- grant_type: 'authorization_code',
101
- code: code,
102
- redirect_uri: REDIRECT_URI,
103
- client_id: CLIENT_ID,
104
- code_verifier: codeVerifier
105
- });
106
-
107
- try {
108
- const tokenResponse = await this.client.post(TOKEN_ENDPOINT, tokenData.toString(), {
109
- headers: {
110
- 'Content-Type': 'application/x-www-form-urlencoded',
111
- 'Authorization': 'Basic aG9tZW1vYmlsZTo='
112
- }
113
- });
114
-
115
- const tokens = tokenResponse.data;
116
- this.emit('warn', `Token obtained: ${JSON.stringify(tokens)}`);
117
- return tokens;
118
- } catch (err) {
119
- throw new Error(`Failed to obtain OAuth token: ${err}`);
120
- }
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
+ }
121
177
  }
178
+
179
+ // No redirect, return final response
180
+ return { url, data: response.data };
181
+ }
182
+
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
+ }
200
+
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}`;
209
+ }
210
+ return `${baseUrl.origin}${baseUrl.pathname.replace(/\/[^/]*$/, '/')}${relative}`;
211
+ }
122
212
  }
123
213
 
214
+
124
215
  export default MelCloudHomeToken;
125
216
 
126
217