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

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.402",
4
+ "version": "4.0.0-beta.404",
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,175 +4,213 @@ 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';
7
+ const MOBILE_USER_AGENT =
8
+ 'MonitorAndControl.App.Mobile/35 CFNetwork/3860.100.1 Darwin/25.0.0';
9
+
8
10
  const CLIENT_ID = 'homemobile';
9
11
  const REDIRECT_URI = 'melcloudhome://';
10
- const SCOPE = 'openid profile';
12
+ const SCOPE = 'openid profile email';
13
+ const AUTH_BASE =
14
+ 'https://live-melcloudhome.auth.eu-west-1.amazoncognito.com';
15
+ const TOKEN_URL = `${AUTH_BASE}/oauth2/token`;
16
+ const AUTHORIZE_URL = `${AUTH_BASE}/login?`;
11
17
 
12
18
  class MelCloudHomeToken extends EventEmitter {
13
19
  constructor() {
14
20
  super();
15
21
  this.cookieJar = new CookieJar();
22
+ this.http = wrapper(axios.create({ jar: this.cookieJar }));
16
23
  }
17
24
 
18
- async getTokens(email, password) {
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`;
24
-
25
- console.log('[OAuth] Starting MELCloud login flow');
26
-
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({
34
- _csrf: csrf,
35
- username: email,
36
- password: password,
37
- });
38
-
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
- }
62
-
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
- }
69
- }
70
-
71
- throw new Error(`No authorization code found (status ${resp.status})`);
25
+ // --- Utility: generate PKCE pair ---
26
+ _generatePkcePair() {
27
+ const verifier = crypto.randomBytes(32).toString('base64url');
28
+ const challenge = crypto
29
+ .createHash('sha256')
30
+ .update(verifier)
31
+ .digest('base64url');
32
+ return { verifier, challenge };
72
33
  }
73
34
 
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({
79
- grant_type: 'authorization_code',
80
- client_id: CLIENT_ID,
81
- code: code,
82
- redirect_uri: REDIRECT_URI,
83
- code_verifier: verifier,
84
- });
85
-
86
- const resp = await axios.post(tokenUrl, data.toString(), {
87
- headers: {
88
- 'User-Agent': MOBILE_USER_AGENT,
89
- 'Content-Type': 'application/x-www-form-urlencoded',
90
- 'Authorization': 'Basic aG9tZW1vYmlsZTo=',
91
- },
92
- validateStatus: () => true,
93
- });
94
-
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}`);
98
- }
99
-
100
- console.log('[OAuth] Tokens received!');
101
- return {
102
- accessToken: resp.data.access_token,
103
- refreshToken: resp.data.refresh_token,
104
- expiresIn: resp.data.expires_in,
105
- };
35
+ // --- Utility: extract CSRF token from HTML ---
36
+ _extractCsrf(html) {
37
+ if (typeof html !== 'string') return null;
38
+ const match = html.match(/name=['"]_csrf['"][^>]*value=['"]([^'"]+)['"]/i);
39
+ return match ? match[1] : null;
106
40
  }
107
41
 
108
- async _get(url) {
42
+ // --- GET request with redirect + cookie management ---
43
+ async _get(url, depth = 0) {
109
44
  const cookies = await this.cookieJar.getCookieString(url);
110
- const resp = await axios.get(url, {
45
+ const resp = await this.http.get(url, {
111
46
  headers: {
112
47
  'User-Agent': MOBILE_USER_AGENT,
113
- 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
114
- 'Cookie': cookies,
48
+ Accept:
49
+ 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
50
+ Cookie: cookies,
115
51
  },
116
- validateStatus: () => true,
117
52
  maxRedirects: 0,
53
+ validateStatus: () => true,
118
54
  responseType: 'text',
55
+ transformResponse: [(d) => d],
119
56
  });
57
+
58
+ // Save cookies
120
59
  if (resp.headers['set-cookie']) {
121
- await this.cookieJar.setCookie(resp.headers['set-cookie'].join('; '), url);
60
+ await Promise.all(
61
+ resp.headers['set-cookie'].map((c) =>
62
+ this.cookieJar.setCookie(c, url)
63
+ )
64
+ );
122
65
  }
66
+
67
+ // Follow redirect manually
68
+ if (
69
+ resp.status >= 300 &&
70
+ resp.status < 400 &&
71
+ resp.headers.location &&
72
+ depth < 5
73
+ ) {
74
+ const nextUrl = new URL(resp.headers.location, url).href;
75
+ console.log(`[OAuth] Redirect → ${nextUrl}`);
76
+ return this._get(nextUrl, depth + 1);
77
+ }
78
+
123
79
  return resp;
124
80
  }
125
81
 
126
- async _post(url, formData) {
82
+ // --- POST request helper ---
83
+ async _post(url, data, headers = {}) {
127
84
  const cookies = await this.cookieJar.getCookieString(url);
128
- const resp = await axios.post(url, formData.toString(), {
85
+ const resp = await this.http.post(url, data, {
129
86
  headers: {
130
87
  'User-Agent': MOBILE_USER_AGENT,
131
88
  'Content-Type': 'application/x-www-form-urlencoded',
132
- 'Accept': 'text/html,application/xhtml+xml',
133
- 'Cookie': cookies,
134
- 'Content-Length': Buffer.byteLength(formData.toString()),
89
+ Cookie: cookies,
90
+ ...headers,
135
91
  },
136
- validateStatus: () => true,
137
92
  maxRedirects: 0,
93
+ validateStatus: () => true,
138
94
  responseType: 'text',
95
+ transformResponse: [(d) => d],
139
96
  });
97
+
140
98
  if (resp.headers['set-cookie']) {
141
- await this.cookieJar.setCookie(resp.headers['set-cookie'].join('; '), url);
99
+ await Promise.all(
100
+ resp.headers['set-cookie'].map((c) =>
101
+ this.cookieJar.setCookie(c, url)
102
+ )
103
+ );
142
104
  }
105
+
143
106
  return resp;
144
107
  }
145
108
 
146
- _extractCsrf(html) {
147
- const match = html.match(/name="_csrf" value="([^"]+)"/);
148
- return match ? match[1] : null;
149
- }
109
+ // --- Main login flow ---
110
+ async login(email, password) {
111
+ const { verifier, challenge } = this._generatePkcePair();
112
+
113
+ // Build authorize URL
114
+ const authUrl =
115
+ `${AUTH_BASE}/oauth2/authorize?` +
116
+ new URLSearchParams({
117
+ client_id: CLIENT_ID,
118
+ redirect_uri: REDIRECT_URI,
119
+ response_type: 'code',
120
+ scope: SCOPE,
121
+ code_challenge: challenge,
122
+ code_challenge_method: 'S256',
123
+ });
124
+
125
+ console.log(`[OAuth] Opening login page...`);
126
+ let resp = await this._get(authUrl);
127
+ if (resp.status >= 300 && resp.status < 400) {
128
+ const redirectUrl = new URL(resp.headers.location, AUTH_BASE).href;
129
+ console.log(`[OAuth] Redirected to: ${redirectUrl}`);
130
+ resp = await this._get(redirectUrl);
131
+ }
150
132
 
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
- }
133
+ // --- Extract CSRF token ---
134
+ const csrf = this._extractCsrf(resp.data);
135
+ if (!csrf) {
136
+ console.error('[OAuth] ❌ Cannot find CSRF token!');
137
+ console.log(resp.data.substring(0, 400));
138
+ throw new Error('Cannot find CSRF token');
139
+ }
157
140
 
158
- _generateCodeVerifier() {
159
- return crypto.randomBytes(32).toString('base64url');
160
- }
141
+ console.log(`[OAuth] Found CSRF token: ${csrf}`);
142
+
143
+ // --- Submit login form ---
144
+ const loginUrl = resp.request.res.responseUrl || `${AUTH_BASE}/login`;
145
+ const formData = new URLSearchParams({
146
+ _csrf: csrf,
147
+ username: email,
148
+ password: password,
149
+ });
150
+
151
+ console.log(`[OAuth] Submitting login form...`);
152
+ const loginResp = await this._post(loginUrl, formData.toString());
153
+
154
+ if (loginResp.status >= 300 && loginResp.status < 400) {
155
+ const redirectUrl = new URL(loginResp.headers.location, AUTH_BASE).href;
156
+ console.log(`[OAuth] Login redirect → ${redirectUrl}`);
161
157
 
162
- _generateCodeChallenge(verifier) {
163
- const hash = crypto.createHash('sha256').update(verifier).digest();
164
- return hash.toString('base64url');
158
+ // Extract code from redirect URL
159
+ const codeMatch = redirectUrl.match(/[?&]code=([^&]+)/);
160
+ if (!codeMatch) throw new Error('No code found after login redirect');
161
+ const code = codeMatch[1];
162
+ console.log(`[OAuth] ✅ Got authorization code: ${code}`);
163
+
164
+ // Exchange code for token
165
+ return this.exchangeToken(code, verifier);
166
+ }
167
+
168
+ console.error('[OAuth] ❌ Unexpected status on login:', loginResp.status);
169
+ console.log(loginResp.data.substring(0, 400));
170
+ throw new Error('Unexpected status on login page');
165
171
  }
166
172
 
167
- _resolveUrl(base, relative) {
168
- try {
169
- return new URL(relative, base).toString();
170
- } catch {
171
- return relative;
173
+ // --- Exchange code for access token ---
174
+ async exchangeToken(code, verifier) {
175
+ console.log('[OAuth] Exchanging code for token...');
176
+
177
+ const body = new URLSearchParams({
178
+ grant_type: 'authorization_code',
179
+ client_id: CLIENT_ID,
180
+ redirect_uri: REDIRECT_URI,
181
+ code,
182
+ code_verifier: verifier,
183
+ });
184
+
185
+ const resp = await this.http.post(TOKEN_URL, body.toString(), {
186
+ headers: {
187
+ 'User-Agent': MOBILE_USER_AGENT,
188
+ 'Content-Type': 'application/x-www-form-urlencoded',
189
+ },
190
+ });
191
+
192
+ if (resp.status !== 200) {
193
+ console.error('[OAuth] ❌ Token exchange failed:', resp.status);
194
+ console.log(resp.data);
195
+ throw new Error('Token exchange failed');
172
196
  }
197
+
198
+ console.log('[OAuth] ✅ Token response received');
199
+ return resp.data;
173
200
  }
174
201
  }
175
202
 
203
+ // --- Example usage ---
204
+ (async () => {
205
+ try {
206
+ const mel = new MelCloudHomeOAuth();
207
+ const token = await mel.login('YOUR_EMAIL', 'YOUR_PASSWORD');
208
+ console.log('✅ Final token:', token);
209
+ } catch (err) {
210
+ console.error('iDom Home, Connect error:', err.message);
211
+ }
212
+ })();
213
+
176
214
 
177
215
  export default MelCloudHomeToken;
178
216