homebridge-melcloud-control 4.0.0-beta.255 → 4.0.0-beta.257

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.255",
4
+ "version": "4.0.0-beta.257",
5
5
  "description": "Homebridge plugin to control Mitsubishi Air Conditioner, Heat Pump and Energy Recovery Ventilation.",
6
6
  "license": "MIT",
7
7
  "author": "grzegorz914",
@@ -40,7 +40,8 @@
40
40
  "axios": "^1.12.2",
41
41
  "express": "^5.1.0",
42
42
  "puppeteer": "^24.26.1",
43
- "jsdom": "^27.0.1"
43
+ "axios-cookiejar-support": "^6.0.4",
44
+ "tough-cookie": "^6.0.0"
44
45
  },
45
46
  "keywords": [
46
47
  "homebridge",
package/src/melcloud.js CHANGED
@@ -177,7 +177,7 @@ class MelCloud extends EventEmitter {
177
177
  // MELCloud Home
178
178
  async connectToMelCloudHome() {
179
179
  const oauth = new MelCloudHomeToken();
180
- this.tokens = await oauth.oauth.getTokensWithCredentials(this.user, this.passwd);
180
+ this.tokens = await oauth.getTokensWithCredentials(this.user, this.passwd);
181
181
  this.emit('warn', `Token ${JSON.stringify(this.tokens, null, 2)}`);
182
182
  //this.axiosInstance.defaults.headers['Authorization'] = `Bearer ${this.tokens.accessToken}`;
183
183
  return false;
@@ -1,8 +1,6 @@
1
- /**
2
- * OAuth Helper - Automatic Token Management (ES Module)
3
- */
4
-
5
- import https from 'https';
1
+ import axios from 'axios';
2
+ import { wrapper } from 'axios-cookiejar-support';
3
+ import { CookieJar } from 'tough-cookie';
6
4
  import crypto from 'crypto';
7
5
 
8
6
  const MOBILE_USER_AGENT = 'MonitorAndControl.App.Mobile/35 CFNetwork/3860.100.1 Darwin/25.0.0';
@@ -13,204 +11,108 @@ const TOKEN_ENDPOINT = 'https://auth.melcloudhome.com/connect/token';
13
11
  const AUTHORIZE_ENDPOINT = 'https://auth.melcloudhome.com/connect/authorize';
14
12
 
15
13
  class MelCloudHomeToken {
16
- constructor(log) {
17
- this.log = log;
18
- }
19
-
20
- generatePKCE() {
21
- const verifier = crypto.randomBytes(32).toString('base64url');
22
- const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
23
- return { verifier, challenge };
24
- }
25
-
26
- generateState() {
27
- return crypto.randomBytes(32).toString('hex');
28
- }
29
-
30
- httpsRequest(url, options, body, redirectCount = 0) {
31
- if (redirectCount > 10) return Promise.reject(new Error('Too many redirects'));
32
-
33
- return new Promise((resolve, reject) => {
34
- const urlObj = new URL(url);
35
- const reqOptions = { ...options, hostname: urlObj.hostname, path: urlObj.pathname + urlObj.search };
36
-
37
- const req = https.request(reqOptions, (res) => {
38
- if ([301, 302].includes(res.statusCode) && res.headers.location) {
39
- const redirectUrl = res.headers.location.startsWith('http')
40
- ? res.headers.location
41
- : `https://${urlObj.hostname}${res.headers.location}`;
42
- return this.httpsRequest(redirectUrl, options, undefined, redirectCount + 1)
43
- .then(resolve).catch(reject);
44
- }
14
+ constructor(log = console) {
15
+ this.log = log;
16
+ const jar = new CookieJar();
17
+ this.client = wrapper(axios.create({ jar, withCredentials: true }));
18
+ this.client.defaults.headers['User-Agent'] = MOBILE_USER_AGENT;
19
+ }
45
20
 
46
- let data = '';
47
- res.on('data', chunk => data += chunk);
48
- res.on('end', () => resolve(data));
49
- });
50
-
51
- req.on('error', reject);
52
- if (body) req.write(body);
53
- req.end();
54
- });
55
- }
56
-
57
- httpsRequestWithCookies(url, options, body, redirectCount = 0, cookies = []) {
58
- if (redirectCount > 10) return Promise.reject(new Error('Too many redirects'));
59
-
60
- return new Promise((resolve, reject) => {
61
- const urlObj = new URL(url);
62
- const reqOptions = { ...options, hostname: urlObj.hostname, path: urlObj.pathname + urlObj.search };
63
-
64
- if (cookies.length) {
65
- reqOptions.headers = { ...(reqOptions.headers || {}), Cookie: cookies.join('; ') };
66
- }
67
-
68
- const req = https.request(reqOptions, (res) => {
69
- const setCookie = res.headers['set-cookie'];
70
- if (setCookie) {
71
- setCookie.forEach(cookie => {
72
- const cookieName = cookie.split('=')[0];
73
- const existingIndex = cookies.findIndex(c => c.startsWith(cookieName + '='));
74
- if (existingIndex >= 0) cookies[existingIndex] = cookie.split(';')[0];
75
- else cookies.push(cookie.split(';')[0]);
76
- });
77
- }
21
+ generatePKCE() {
22
+ const verifier = crypto.randomBytes(32).toString('base64url');
23
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
24
+ return { verifier, challenge };
25
+ }
26
+
27
+ generateState() {
28
+ return crypto.randomBytes(32).toString('hex');
29
+ }
30
+
31
+ async getLoginPage(authUrl) {
32
+ this.log.debug('Getting login page...');
33
+ const res = await this.client.get(authUrl, { maxRedirects: 10 });
34
+ const csrfMatch = res.data.match(/name="_csrf"\s+value="([^"]+)"/);
35
+ if (!csrfMatch) throw new Error('Could not extract CSRF token from login page');
36
+ return { html: res.data, csrf: csrfMatch[1], url: res.request.res.responseUrl };
37
+ }
38
+
39
+ async submitLogin(loginUrl, csrf, email, password) {
40
+ const form = new URLSearchParams({ _csrf: csrf, username: email, password });
41
+ const res = await this.client.post(loginUrl, form.toString(), {
42
+ headers: {
43
+ 'Content-Type': 'application/x-www-form-urlencoded',
44
+ Referer: loginUrl,
45
+ },
46
+ maxRedirects: 0, // manual redirect handling
47
+ validateStatus: (status) => status >= 200 && status < 400,
48
+ });
78
49
 
79
- if ([301, 302].includes(res.statusCode) && res.headers.location) {
80
- const redirectUrl = res.headers.location.startsWith('http')
81
- ? res.headers.location
82
- : `https://${urlObj.hostname}${res.headers.location}`;
83
- return this.httpsRequestWithCookies(redirectUrl, options, undefined, redirectCount + 1, cookies)
84
- .then(resolve).catch(reject);
50
+ // Try to get code from Location header
51
+ const location = res.headers.location;
52
+ if (location?.startsWith('melcloudhome://')) {
53
+ const codeMatch = location.match(/[?&]code=([^&]+)/);
54
+ if (codeMatch) return codeMatch[1];
85
55
  }
86
56
 
87
- let data = '';
88
- res.on('data', chunk => data += chunk);
89
- res.on('end', () => resolve({ html: data, cookies, finalUrl: url }));
90
- });
91
-
92
- req.on('error', reject);
93
- if (body) req.write(body);
94
- req.end();
95
- });
96
- }
97
-
98
- async getLoginPage(authUrl) {
99
- console.log('Getting login page...');
100
- const cookies = [];
101
- const response = await this.httpsRequestWithCookies(authUrl, {
102
- method: 'GET',
103
- headers: {
104
- 'User-Agent': MOBILE_USER_AGENT,
105
- Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
106
- 'Accept-Language': 'en-US,en;q=0.9',
107
- },
108
- }, undefined, 0, cookies);
109
-
110
- const csrfMatch = response.html.match(/name="_csrf"\s+value="([^"]+)"/);
111
- if (!csrfMatch) throw new Error('Could not extract CSRF token from login page');
112
-
113
- return {
114
- html: response.html,
115
- loginUrl: response.finalUrl,
116
- csrf: csrfMatch[1],
117
- cookies: response.cookies.join('; '),
118
- };
119
- }
120
-
121
- submitLogin(loginUrl, csrf, email, password, cookies) {
122
- const formData = new URLSearchParams({ _csrf: csrf, username: email, password });
123
- return new Promise((resolve, reject) => {
124
- const urlObj = new URL(loginUrl);
125
- const req = https.request({
126
- hostname: urlObj.hostname,
127
- path: urlObj.pathname + urlObj.search,
128
- method: 'POST',
129
- headers: {
130
- 'User-Agent': MOBILE_USER_AGENT,
131
- Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
132
- 'Accept-Language': 'en-US,en;q=0.9',
133
- 'Content-Type': 'application/x-www-form-urlencoded',
134
- 'Content-Length': formData.toString().length,
135
- Cookie: cookies,
136
- Origin: 'https://live-melcloudhome.auth.eu-west-1.amazoncognito.com',
137
- Referer: loginUrl,
138
- },
139
- }, (res) => {
140
- let data = '';
141
- res.on('data', chunk => data += chunk);
142
- res.on('end', () => {
143
- const locationHeader = res.headers.location;
144
- const bodyCodeMatch = data.match(/melcloudhome:\/\/[^"'\s]*[?&]code=([^&"'\s]+)/);
145
- if (locationHeader && locationHeader.startsWith('melcloudhome://')) {
146
- const codeMatch = locationHeader.match(/[?&]code=([^&]+)/);
147
- if (codeMatch) return resolve(codeMatch[1]);
148
- } else if (bodyCodeMatch) return resolve(bodyCodeMatch[1]);
149
- reject(new Error('No authorization code found in login response'));
57
+ // Try to get code from HTML (form_post or JS)
58
+ const codeMatch = res.data.match(/name="code"\s+value="([^"]+)"/) ||
59
+ res.data.match(/melcloudhome:\/\/[^"'\s]*[?&]code=([^&"'\s]+)/);
60
+ if (codeMatch) return codeMatch[1];
61
+
62
+ throw new Error('Authorization code not found in login response');
63
+ }
64
+
65
+ async exchangeCodeForTokens(code, codeVerifier) {
66
+ const form = new URLSearchParams({
67
+ grant_type: 'authorization_code',
68
+ code,
69
+ redirect_uri: REDIRECT_URI,
70
+ client_id: CLIENT_ID,
71
+ code_verifier: codeVerifier,
72
+ });
73
+
74
+ const res = await this.client.post(TOKEN_ENDPOINT, form.toString(), {
75
+ headers: {
76
+ 'Content-Type': 'application/x-www-form-urlencoded',
77
+ Authorization: 'Basic aG9tZW1vYmlsZTo=',
78
+ },
150
79
  });
151
- });
152
-
153
- req.on('error', reject);
154
- req.write(formData.toString());
155
- req.end();
156
- });
157
- }
158
-
159
- async exchangeCodeForTokens(code, codeVerifier) {
160
- const tokenData = new URLSearchParams({
161
- grant_type: 'authorization_code',
162
- code,
163
- redirect_uri: REDIRECT_URI,
164
- client_id: CLIENT_ID,
165
- code_verifier: codeVerifier,
166
- });
167
-
168
- const response = await this.httpsRequest(TOKEN_ENDPOINT, {
169
- method: 'POST',
170
- headers: {
171
- 'Content-Type': 'application/x-www-form-urlencoded',
172
- 'Content-Length': tokenData.toString().length,
173
- Authorization: 'Basic aG9tZW1vYmlsZTo=',
174
- },
175
- }, tokenData.toString());
176
-
177
- const tokens = JSON.parse(response);
178
- if (tokens.error) throw new Error(`Token exchange failed: ${tokens.error_description || tokens.error}`);
179
-
180
- return {
181
- accessToken: tokens.access_token,
182
- refreshToken: tokens.refresh_token,
183
- expiresIn: tokens.expires_in,
184
- idToken: tokens.id_token,
185
- };
186
- }
187
-
188
- async getTokensWithCredentials(email, password) {
189
- try {
190
- console.log('Obtaining OAuth tokens automatically...');
191
- const pkce = this.generatePKCE();
192
- const state = this.generateState();
193
-
194
- const authUrl = new URL(AUTHORIZE_ENDPOINT);
195
- authUrl.searchParams.set('client_id', CLIENT_ID);
196
- authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
197
- authUrl.searchParams.set('response_type', 'code');
198
- authUrl.searchParams.set('scope', SCOPE);
199
- authUrl.searchParams.set('code_challenge', pkce.challenge);
200
- authUrl.searchParams.set('code_challenge_method', 'S256');
201
- authUrl.searchParams.set('state', state);
202
-
203
- const loginPage = await this.getLoginPage(authUrl.toString());
204
- const authCode = await this.submitLogin(loginPage.loginUrl, loginPage.csrf, email, password, loginPage.cookies);
205
- const tokens = await this.exchangeCodeForTokens(authCode, pkce.verifier);
206
-
207
- console.log('✓ Successfully obtained OAuth tokens');
208
- return tokens;
209
- } catch (error) {
210
- console.log('Failed to obtain OAuth tokens:', error);
211
- throw error;
80
+
81
+ if (res.data.error) throw new Error(`Token exchange failed: ${res.data.error_description || res.data.error}`);
82
+ return {
83
+ accessToken: res.data.access_token,
84
+ refreshToken: res.data.refresh_token,
85
+ expiresIn: res.data.expires_in,
86
+ idToken: res.data.id_token,
87
+ };
88
+ }
89
+
90
+ async getTokensWithCredentials(email, password) {
91
+ try {
92
+ this.log.info('Obtaining OAuth tokens...');
93
+ const pkce = this.generatePKCE();
94
+ const state = this.generateState();
95
+
96
+ const authUrl = new URL(AUTHORIZE_ENDPOINT);
97
+ authUrl.searchParams.set('client_id', CLIENT_ID);
98
+ authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
99
+ authUrl.searchParams.set('response_type', 'code');
100
+ authUrl.searchParams.set('scope', SCOPE);
101
+ authUrl.searchParams.set('code_challenge', pkce.challenge);
102
+ authUrl.searchParams.set('code_challenge_method', 'S256');
103
+ authUrl.searchParams.set('state', state);
104
+
105
+ const loginPage = await this.getLoginPage(authUrl.toString());
106
+ const authCode = await this.submitLogin(loginPage.url, loginPage.csrf, email, password);
107
+ const tokens = await this.exchangeCodeForTokens(authCode, pkce.verifier);
108
+
109
+ this.log.info('✓ Successfully obtained OAuth tokens');
110
+ return tokens;
111
+ } catch (error) {
112
+ this.log.error('Failed to obtain OAuth tokens:', error);
113
+ throw error;
114
+ }
212
115
  }
213
- }
214
116
  }
215
117
 
216
118