homebridge-melcloud-control 4.0.0-beta.244 → 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.244",
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",
@@ -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,85 +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: {
40
88
  'User-Agent': MOBILE_USER_AGENT,
41
89
  'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
42
- 'Accept-Language': 'en-US,en;q=0.9',
43
- },
44
- validateStatus: () => true
90
+ }
45
91
  });
46
92
 
47
- const html = resp.data;
48
- const formSnippet = html.match(/<form[^>]*>[\s\S]{0,800}/);
49
- if (formSnippet) console.log(`Form HTML preview: ${formSnippet[0].replace(/\s+/g, ' ').substring(0, 500)}`);
50
-
51
- const csrfMatch = html.match(/name="_csrf"\s+value="([^"]+)"/);
52
- 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');
53
96
 
54
- const cookies = await this.jar.getCookieString(authUrl) || '';
55
- return { html, loginUrl: resp.request.res.responseUrl, csrf: csrfMatch[1], cookies };
97
+ return { formHtml: resp.html, cookies: resp.cookies, form };
56
98
  }
57
99
 
58
- async submitLogin(loginUrl, email, password, cookies) {
100
+ // --- Submit login ---
101
+ async submitLogin(form, email, password, cookies, loginPageUrl) {
59
102
  console.log('Submitting login credentials...');
60
103
 
61
- // 1. Pobieramy stronę logowania
62
- const resp = await this.axiosInstance.get(loginUrl, {
63
- headers: { Cookie: cookies },
64
- maxRedirects: 0,
65
- validateStatus: s => s >= 200 && s < 400
66
- }).catch(err => err.response || err);
67
-
68
- const html = resp.data;
69
-
70
- // 2. Parsujemy formę logowania i zbieramy hidden inputs
71
- const dom = new JSDOM(html);
72
- const form = dom.window.document.querySelector('form');
73
- if (!form) throw new Error('Nie znaleziono formy logowania');
74
-
104
+ // Collect all hidden inputs
75
105
  const formData = new URLSearchParams();
76
- for (const input of form.querySelectorAll('input')) {
106
+ form.querySelectorAll('input').forEach(input => {
77
107
  const name = input.getAttribute('name');
78
- const value = input.getAttribute('value') || '';
79
- formData.append(name, value);
80
- }
108
+ if (name) formData.append(name, input.getAttribute('value') || '');
109
+ });
81
110
 
82
- // 3. Nadpisujemy username i password
111
+ // Overwrite username/password
83
112
  formData.set('username', email);
84
113
  formData.set('password', password);
85
114
 
86
- 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);
87
120
 
88
- // 4. Wysyłamy POST do form action
89
- return this.submitFormPost(actionUrl, formData);
90
- }
121
+ // Try to extract code from redirect
122
+ const codeFromLocation = resp.finalUrl.match(/[?&]code=([^&]+)/);
123
+ if (codeFromLocation) return codeFromLocation[1];
91
124
 
92
- async submitFormPost(actionUrl, formData) {
93
- const resp = await this.axiosInstance.post(actionUrl, formData.toString(), {
94
- headers: {
95
- 'Content-Type': 'application/x-www-form-urlencoded',
96
- },
97
- maxRedirects: 0,
98
- validateStatus: s => s >= 200 && s < 400
99
- }).catch(err => err.response || err);
100
-
101
- // 5. Szukamy authorization code w redirect
102
- const locationHeader = resp.headers?.location;
103
- if (locationHeader && locationHeader.startsWith('melcloudhome://')) {
104
- const codeMatch = locationHeader.match(/[?&]code=([^&]+)/);
105
- if (codeMatch) return codeMatch[1];
106
- }
107
-
108
- // 6. Albo w body (czasami JS redirect)
109
- const bodyCodeMatch = resp.data?.match(/melcloudhome:\/\/[^"'\s]*[?&]code=([^&"'\s]+)/);
110
- 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];
111
128
 
112
129
  throw new Error('No authorization code found after login');
113
130
  }
114
131
 
132
+ // --- Exchange code for tokens ---
115
133
  async exchangeCodeForTokens(code, codeVerifier) {
116
134
  console.log('Exchanging authorization code for tokens...');
117
135
 
@@ -120,28 +138,27 @@ class MelCloudHomeToken {
120
138
  code,
121
139
  redirect_uri: REDIRECT_URI,
122
140
  client_id: CLIENT_ID,
123
- code_verifier: codeVerifier
141
+ code_verifier: codeVerifier,
124
142
  });
125
143
 
126
- const resp = await this.axiosInstance.post(TOKEN_ENDPOINT, tokenData.toString(), {
144
+ const resp = await this.httpsRequest(TOKEN_ENDPOINT, {
145
+ method: 'POST',
127
146
  headers: {
128
147
  'Content-Type': 'application/x-www-form-urlencoded',
129
- 'Authorization': 'Basic aG9tZW1vYmlsZTo='
148
+ 'Content-Length': tokenData.toString().length,
149
+ 'Authorization': 'Basic aG9tZW1vYmlsZTo=', // base64(homemobile:)
130
150
  }
131
- });
132
-
133
- if (resp.data.error) throw new Error(`Token exchange failed: ${resp.data.error_description || resp.data.error}`);
151
+ }, tokenData.toString());
134
152
 
135
- return {
136
- accessToken: resp.data.access_token,
137
- refreshToken: resp.data.refresh_token,
138
- expiresIn: resp.data.expires_in,
139
- idToken: resp.data.id_token
140
- };
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;
141
156
  }
142
157
 
158
+ // --- Main flow ---
143
159
  async getTokensWithCredentials(email, password) {
144
160
  console.log('Obtaining OAuth tokens automatically...');
161
+
145
162
  const pkce = this.generatePKCE();
146
163
  const state = this.generateState();
147
164
 
@@ -154,10 +171,14 @@ class MelCloudHomeToken {
154
171
  authUrl.searchParams.set('code_challenge_method', 'S256');
155
172
  authUrl.searchParams.set('state', state);
156
173
 
157
- const loginPage = await this.getLoginPage(authUrl.toString());
158
- const authCode = await this.submitLogin(loginPage.loginUrl, loginPage.csrf, email, password, loginPage.cookies);
159
- const tokens = await this.exchangeCodeForTokens(authCode, pkce.verifier);
174
+ // 1️⃣ get login page
175
+ const { form, cookies } = await this.getLoginPage(authUrl.toString());
160
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);
161
182
  console.log('✓ Successfully obtained OAuth tokens');
162
183
  return tokens;
163
184
  }