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

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.401",
5
5
  "description": "Homebridge plugin to control Mitsubishi Air Conditioner, Heat Pump and Energy Recovery Ventilation.",
6
6
  "license": "MIT",
7
7
  "author": "grzegorz914",
@@ -7,209 +7,164 @@ import EventEmitter from 'events';
7
7
  class MelCloudHomeToken extends EventEmitter {
8
8
  constructor() {
9
9
  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
- }));
10
+ this.cookieJar = new CookieJar();
29
11
  }
30
12
 
31
- /**
32
- * Entry point: logs in and returns tokens
33
- */
34
13
  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
- }
14
+ const verifier = this._generateCodeVerifier();
15
+ const challenge = this._generateCodeChallenge(verifier);
16
+ const authUrl = `https://live-melcloudhome.auth.eu-west-1.amazoncognito.com/login?client_id=3g4d5l5kivuqi7oia68gib7uso&redirect_uri=${encodeURIComponent(
17
+ 'https://auth.melcloudhome.com/signin-oidc-meu'
18
+ )}&response_type=code&scope=openid%20profile&code_challenge=${challenge}&code_challenge_method=S256`;
19
+
20
+ console.log('[OAuth] Starting MELCloud login flow');
52
21
 
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');
22
+ const html = await this._get(authUrl);
23
+ const csrf = this._extractCsrf(html);
24
+ if (!csrf) throw new Error('Cannot find CSRF token');
58
25
 
59
- // Step 2: POST credentials
60
- const formData = new URLSearchParams({
26
+ console.log('[OAuth] Submitting credentials...');
27
+
28
+ const form = new URLSearchParams({
61
29
  _csrf: csrf,
62
30
  username: email,
63
- password: password
31
+ password: password,
64
32
  });
65
33
 
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
- );
34
+ const loginResp = await this._post('https://live-melcloudhome.auth.eu-west-1.amazoncognito.com/login', form);
35
+
36
+ if (loginResp.status === 302 && loginResp.headers.location) {
37
+ const nextUrl = this._resolveUrl('https://live-melcloudhome.auth.eu-west-1.amazoncognito.com', loginResp.headers.location);
38
+ console.log('[OAuth] Redirecting to:', nextUrl);
39
+ return await this._followRedirects(nextUrl, verifier);
40
+ } else if (loginResp.status === 200) {
41
+ throw new Error('Login failed — probably wrong credentials');
42
+ } else {
43
+ throw new Error(`Unexpected login status: ${loginResp.status}`);
44
+ }
45
+ }
80
46
 
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');
47
+ async _followRedirects(url, verifier, depth = 0) {
48
+ if (depth > 10) throw new Error('Too many redirects');
49
+
50
+ const resp = await this._get(url);
51
+
52
+ if (resp.status === 302 && resp.headers.location) {
53
+ const nextUrl = this._resolveUrl(url, resp.headers.location);
54
+ console.log(`[OAuth] Redirect (${resp.status}) to: ${nextUrl}`);
55
+ return await this._followRedirects(nextUrl, verifier, depth + 1);
56
+ }
57
+
58
+ if (typeof resp.data === 'string') {
59
+ const code = this._extractAuthCode(resp.data);
60
+ if (code) {
61
+ console.log('[OAuth] Got authorization code');
62
+ return await this._exchangeToken(code, verifier);
63
+ }
85
64
  }
86
65
 
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');
66
+ throw new Error(`No authorization code found (status ${resp.status})`);
67
+ }
68
+
69
+ async _exchangeToken(code, verifier) {
70
+ console.log('[OAuth] Exchanging code for tokens...');
91
71
 
92
- // Step 3: Exchange code for tokens
93
- console.log('[OAuth] Step 3: Exchange code for tokens');
94
- const tokenData = new URLSearchParams({
72
+ const tokenUrl = 'https://auth.melcloudhome.com/connect/token';
73
+ const data = new URLSearchParams({
95
74
  grant_type: 'authorization_code',
96
- code: authCode,
97
- redirect_uri: this.REDIRECT_URI,
98
- client_id: this.CLIENT_ID,
99
- code_verifier: codeVerifier
75
+ client_id: CLIENT_ID,
76
+ code: code,
77
+ redirect_uri: REDIRECT_URI,
78
+ code_verifier: verifier,
100
79
  });
101
80
 
102
- const tokenResponse = await this.client.post(this.TOKEN_URL, tokenData.toString(), {
81
+ const resp = await axios.post(tokenUrl, data.toString(), {
103
82
  headers: {
83
+ 'User-Agent': MOBILE_USER_AGENT,
104
84
  'Content-Type': 'application/x-www-form-urlencoded',
105
- 'Authorization': 'Basic aG9tZW1vYmlsZTo=', // homemobile:
106
- }
85
+ 'Authorization': 'Basic aG9tZW1vYmlsZTo=',
86
+ },
87
+ validateStatus: () => true,
107
88
  });
108
89
 
109
- if (tokenResponse.status !== 200) {
110
- console.error('[OAuth] Token error response:', tokenResponse.data);
111
- throw new Error(`Token exchange failed (HTTP ${tokenResponse.status})`);
90
+ if (resp.status !== 200) {
91
+ console.error('[OAuth] Token exchange failed:', resp.status, resp.data);
92
+ throw new Error(`Token exchange failed with status ${resp.status}`);
112
93
  }
113
94
 
114
- console.log('[OAuth] Token exchange successful');
95
+ console.log('[OAuth] Tokens received!');
115
96
  return {
116
- refreshToken: tokenResponse.data.refresh_token,
117
- accessToken: tokenResponse.data.access_token,
118
- expiresIn: tokenResponse.data.expires_in,
97
+ accessToken: resp.data.access_token,
98
+ refreshToken: resp.data.refresh_token,
99
+ expiresIn: resp.data.expires_in,
119
100
  };
120
101
  }
121
102
 
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 };
103
+ async _get(url) {
104
+ const cookies = await this.cookieJar.getCookieString(url);
105
+ const resp = await axios.get(url, {
106
+ headers: {
107
+ 'User-Agent': MOBILE_USER_AGENT,
108
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
109
+ 'Cookie': cookies,
110
+ },
111
+ validateStatus: () => true,
112
+ maxRedirects: 0,
113
+ responseType: 'text',
114
+ });
115
+ if (resp.headers['set-cookie']) {
116
+ await this.cookieJar.setCookie(resp.headers['set-cookie'].join('; '), url);
134
117
  }
118
+ return resp;
119
+ }
135
120
 
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
- }
121
+ async _post(url, formData) {
122
+ const cookies = await this.cookieJar.getCookieString(url);
123
+ const resp = await axios.post(url, formData.toString(), {
124
+ headers: {
125
+ 'User-Agent': MOBILE_USER_AGENT,
126
+ 'Content-Type': 'application/x-www-form-urlencoded',
127
+ 'Accept': 'text/html,application/xhtml+xml',
128
+ 'Cookie': cookies,
129
+ 'Content-Length': Buffer.byteLength(formData.toString()),
130
+ },
131
+ validateStatus: () => true,
132
+ maxRedirects: 0,
133
+ responseType: 'text',
134
+ });
135
+ if (resp.headers['set-cookie']) {
136
+ await this.cookieJar.setCookie(resp.headers['set-cookie'].join('; '), url);
163
137
  }
138
+ return resp;
139
+ }
164
140
 
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
- }
141
+ _extractCsrf(html) {
142
+ const match = html.match(/name="_csrf" value="([^"]+)"/);
143
+ return match ? match[1] : null;
144
+ }
171
145
 
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
- }
146
+ _extractAuthCode(html) {
147
+ const urlMatch = html.match(/[?&]code=([^&]+)/);
148
+ if (urlMatch) return urlMatch[1];
149
+ const formMatch = html.match(/name="code"\s+value="([^"]+)"/);
150
+ return formMatch ? formMatch[1] : null;
151
+ }
180
152
 
181
- // No redirect, return final response
182
- return { url, data: response.data };
153
+ _generateCodeVerifier() {
154
+ return crypto.randomBytes(32).toString('base64url');
183
155
  }
184
156
 
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 };
157
+ _generateCodeChallenge(verifier) {
158
+ const hash = crypto.createHash('sha256').update(verifier).digest();
159
+ return hash.toString('base64url');
201
160
  }
202
161
 
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}`;
162
+ _resolveUrl(base, relative) {
163
+ try {
164
+ return new URL(relative, base).toString();
165
+ } catch {
166
+ return relative;
211
167
  }
212
- return `${baseUrl.origin}${baseUrl.pathname.replace(/\/[^/]*$/, '/')}${relative}`;
213
168
  }
214
169
  }
215
170