homebridge-melcloud-control 4.0.0-beta.243 → 4.0.0-beta.245

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.243",
4
+ "version": "4.0.0-beta.245",
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
@@ -176,7 +176,7 @@ class MelCloud extends EventEmitter {
176
176
 
177
177
  // MELCloud Home
178
178
  async connectToMelCloudHome() {
179
- const oauth = new MelCloudHomeToken(this.log);
179
+ const oauth = new MelCloudHomeToken();
180
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}`;
@@ -1,8 +1,7 @@
1
1
  // oauth-helper-axios.js
2
- import axios from 'axios';
3
- import { CookieJar } from 'tough-cookie';
4
- import { wrapper } from 'axios-cookiejar-support';
2
+ import https from 'https';
5
3
  import crypto from 'crypto';
4
+ import { URL, URLSearchParams } from 'url';
6
5
  import { JSDOM } from 'jsdom';
7
6
 
8
7
  const MOBILE_USER_AGENT = 'MonitorAndControl.App.Mobile/35 CFNetwork/3860.100.1 Darwin/25.0.0';
@@ -23,6 +22,7 @@ class MelCloudHomeToken {
23
22
  }));
24
23
  }
25
24
 
25
+ // --- PKCE ---
26
26
  generatePKCE() {
27
27
  const verifier = crypto.randomBytes(32).toString('base64url');
28
28
  const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
@@ -33,84 +33,103 @@ class MelCloudHomeToken {
33
33
  return crypto.randomBytes(32).toString('hex');
34
34
  }
35
35
 
36
+ // --- HTTPS request with cookies ---
37
+ httpsRequest(url, options = {}, body = undefined, cookies = [], redirectCount = 0) {
38
+ if (redirectCount > 10) throw new Error('Too many redirects');
39
+
40
+ return new Promise((resolve, reject) => {
41
+ const urlObj = new URL(url);
42
+ const reqOptions = {
43
+ ...options,
44
+ hostname: urlObj.hostname,
45
+ path: urlObj.pathname + urlObj.search,
46
+ };
47
+
48
+ if (!reqOptions.headers) reqOptions.headers = {};
49
+ if (cookies.length > 0) reqOptions.headers['Cookie'] = cookies.join('; ');
50
+
51
+ const req = https.request(reqOptions, (res) => {
52
+ // collect Set-Cookie
53
+ const setCookie = res.headers['set-cookie'];
54
+ if (setCookie) {
55
+ setCookie.forEach(c => {
56
+ const name = c.split('=')[0];
57
+ const idx = cookies.findIndex(x => x.startsWith(name + '='));
58
+ if (idx >= 0) cookies[idx] = c.split(';')[0];
59
+ else cookies.push(c.split(';')[0]);
60
+ });
61
+ }
62
+
63
+ // follow redirect
64
+ if ([301, 302, 303].includes(res.statusCode) && res.headers.location) {
65
+ const redirectUrl = res.headers.location.startsWith('http')
66
+ ? res.headers.location
67
+ : `https://${urlObj.hostname}${res.headers.location}`;
68
+ return resolve(this.httpsRequest(redirectUrl, options, undefined, cookies, redirectCount + 1));
69
+ }
70
+
71
+ let data = '';
72
+ res.on('data', chunk => data += chunk);
73
+ res.on('end', () => resolve({ html: data, cookies, finalUrl: url }));
74
+ });
75
+
76
+ req.on('error', reject);
77
+ if (body) req.write(body);
78
+ req.end();
79
+ });
80
+ }
81
+
82
+ // --- Get login page ---
36
83
  async getLoginPage(authUrl) {
37
84
  console.log('Getting login page...');
38
- const resp = await this.axiosInstance.get(authUrl, {
85
+ const resp = await this.httpsRequest(authUrl, {
86
+ method: 'GET',
39
87
  headers: {
88
+ 'User-Agent': MOBILE_USER_AGENT,
40
89
  'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
41
- 'Accept-Language': 'en-US,en;q=0.9',
42
- },
43
- validateStatus: () => true
90
+ }
44
91
  });
45
92
 
46
- const html = resp.data;
47
- const formSnippet = html.match(/<form[^>]*>[\s\S]{0,800}/);
48
- if (formSnippet) console.log(`Form HTML preview: ${formSnippet[0].replace(/\s+/g, ' ').substring(0, 500)}`);
49
-
50
- const csrfMatch = html.match(/name="_csrf"\s+value="([^"]+)"/);
51
- if (!csrfMatch) throw new Error('Could not extract CSRF token from login page');
93
+ const dom = new JSDOM(resp.html);
94
+ const form = dom.window.document.querySelector('form');
95
+ if (!form) throw new Error('Login form not found');
52
96
 
53
- const cookies = await this.jar.getCookieString(authUrl) || '';
54
- return { html, loginUrl: resp.request.res.responseUrl, csrf: csrfMatch[1], cookies };
97
+ return { formHtml: resp.html, cookies: resp.cookies, form };
55
98
  }
56
99
 
57
- async submitLogin(loginUrl, email, password, cookies) {
100
+ // --- Submit login ---
101
+ async submitLogin(form, email, password, cookies, loginPageUrl) {
58
102
  console.log('Submitting login credentials...');
59
103
 
60
- // 1. Pobieramy stronę logowania
61
- const resp = await this.axiosInstance.get(loginUrl, {
62
- headers: { Cookie: cookies },
63
- maxRedirects: 0,
64
- validateStatus: s => s >= 200 && s < 400
65
- }).catch(err => err.response || err);
66
-
67
- const html = resp.data;
68
-
69
- // 2. Parsujemy formę logowania i zbieramy hidden inputs
70
- const dom = new JSDOM(html);
71
- const form = dom.window.document.querySelector('form');
72
- if (!form) throw new Error('Nie znaleziono formy logowania');
73
-
104
+ // Collect all hidden inputs
74
105
  const formData = new URLSearchParams();
75
- for (const input of form.querySelectorAll('input')) {
106
+ form.querySelectorAll('input').forEach(input => {
76
107
  const name = input.getAttribute('name');
77
- const value = input.getAttribute('value') || '';
78
- formData.append(name, value);
79
- }
108
+ if (name) formData.append(name, input.getAttribute('value') || '');
109
+ });
80
110
 
81
- // 3. Nadpisujemy username i password
111
+ // Overwrite username/password
82
112
  formData.set('username', email);
83
113
  formData.set('password', password);
84
114
 
85
- const actionUrl = new URL(form.getAttribute('action'), loginUrl).toString();
115
+ const actionUrl = new URL(form.getAttribute('action'), loginPageUrl).toString();
116
+ const resp = await this.httpsRequest(actionUrl, {
117
+ method: 'POST',
118
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
119
+ }, formData.toString(), cookies);
86
120
 
87
- // 4. Wysyłamy POST do form action
88
- return this.submitFormPost(actionUrl, formData);
89
- }
121
+ // Try to extract code from redirect
122
+ const codeFromLocation = resp.finalUrl.match(/[?&]code=([^&]+)/);
123
+ if (codeFromLocation) return codeFromLocation[1];
90
124
 
91
- async submitFormPost(actionUrl, formData) {
92
- const resp = await this.axiosInstance.post(actionUrl, formData.toString(), {
93
- headers: {
94
- 'Content-Type': 'application/x-www-form-urlencoded',
95
- },
96
- maxRedirects: 0,
97
- validateStatus: s => s >= 200 && s < 400
98
- }).catch(err => err.response || err);
99
-
100
- // 5. Szukamy authorization code w redirect
101
- const locationHeader = resp.headers?.location;
102
- if (locationHeader && locationHeader.startsWith('melcloudhome://')) {
103
- const codeMatch = locationHeader.match(/[?&]code=([^&]+)/);
104
- if (codeMatch) return codeMatch[1];
105
- }
106
-
107
- // 6. Albo w body (czasami JS redirect)
108
- const bodyCodeMatch = resp.data?.match(/melcloudhome:\/\/[^"'\s]*[?&]code=([^&"'\s]+)/);
109
- if (bodyCodeMatch) return bodyCodeMatch[1];
125
+ // Or from body (JS redirect or hidden form_post)
126
+ const codeFromBody = resp.html.match(/melcloudhome:\/\/[^"'\s]*[?&]code=([^&"'\s]+)/);
127
+ if (codeFromBody) return codeFromBody[1];
110
128
 
111
129
  throw new Error('No authorization code found after login');
112
130
  }
113
131
 
132
+ // --- Exchange code for tokens ---
114
133
  async exchangeCodeForTokens(code, codeVerifier) {
115
134
  console.log('Exchanging authorization code for tokens...');
116
135
 
@@ -119,28 +138,27 @@ class MelCloudHomeToken {
119
138
  code,
120
139
  redirect_uri: REDIRECT_URI,
121
140
  client_id: CLIENT_ID,
122
- code_verifier: codeVerifier
141
+ code_verifier: codeVerifier,
123
142
  });
124
143
 
125
- const resp = await this.axiosInstance.post(TOKEN_ENDPOINT, tokenData.toString(), {
144
+ const resp = await this.httpsRequest(TOKEN_ENDPOINT, {
145
+ method: 'POST',
126
146
  headers: {
127
147
  'Content-Type': 'application/x-www-form-urlencoded',
128
- 'Authorization': 'Basic aG9tZW1vYmlsZTo='
148
+ 'Content-Length': tokenData.toString().length,
149
+ 'Authorization': 'Basic aG9tZW1vYmlsZTo=', // base64(homemobile:)
129
150
  }
130
- });
131
-
132
- if (resp.data.error) throw new Error(`Token exchange failed: ${resp.data.error_description || resp.data.error}`);
151
+ }, tokenData.toString());
133
152
 
134
- return {
135
- accessToken: resp.data.access_token,
136
- refreshToken: resp.data.refresh_token,
137
- expiresIn: resp.data.expires_in,
138
- idToken: resp.data.id_token
139
- };
153
+ const tokens = JSON.parse(resp.html);
154
+ if (tokens.error) throw new Error(`Token exchange failed: ${tokens.error_description || tokens.error}`);
155
+ return tokens;
140
156
  }
141
157
 
158
+ // --- Main flow ---
142
159
  async getTokensWithCredentials(email, password) {
143
160
  console.log('Obtaining OAuth tokens automatically...');
161
+
144
162
  const pkce = this.generatePKCE();
145
163
  const state = this.generateState();
146
164
 
@@ -153,10 +171,14 @@ class MelCloudHomeToken {
153
171
  authUrl.searchParams.set('code_challenge_method', 'S256');
154
172
  authUrl.searchParams.set('state', state);
155
173
 
156
- const loginPage = await this.getLoginPage(authUrl.toString());
157
- const authCode = await this.submitLogin(loginPage.loginUrl, loginPage.csrf, email, password, loginPage.cookies);
158
- const tokens = await this.exchangeCodeForTokens(authCode, pkce.verifier);
174
+ // 1️⃣ get login page
175
+ const { form, cookies } = await this.getLoginPage(authUrl.toString());
159
176
 
177
+ // 2️⃣ submit login
178
+ const authCode = await this.submitLogin(form, email, password, cookies, authUrl.toString());
179
+
180
+ // 3️⃣ exchange code for tokens
181
+ const tokens = await this.exchangeCodeForTokens(authCode, pkce.verifier);
160
182
  console.log('✓ Successfully obtained OAuth tokens');
161
183
  return tokens;
162
184
  }