homebridge-melcloud-control 4.0.0-beta.400 → 4.0.0-beta.402

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