homebridge-melcloud-control 4.0.0-beta.244 → 4.0.0-beta.246

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.246",
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';
@@ -13,16 +12,11 @@ const TOKEN_ENDPOINT = 'https://auth.melcloudhome.com/connect/token';
13
12
  const AUTHORIZE_ENDPOINT = 'https://auth.melcloudhome.com/connect/authorize';
14
13
 
15
14
  class MelCloudHomeToken {
16
- constructor(log) {
17
- this.jar = new CookieJar();
18
- this.axiosInstance = wrapper(axios.create({
19
- jar: this.jar,
20
- withCredentials: true,
21
- headers: { 'User-Agent': MOBILE_USER_AGENT },
22
- maxRedirects: 10
23
- }));
15
+ constructor() {
16
+
24
17
  }
25
18
 
19
+ // --- PKCE ---
26
20
  generatePKCE() {
27
21
  const verifier = crypto.randomBytes(32).toString('base64url');
28
22
  const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
@@ -33,85 +27,103 @@ class MelCloudHomeToken {
33
27
  return crypto.randomBytes(32).toString('hex');
34
28
  }
35
29
 
30
+ // --- HTTPS request with cookies ---
31
+ httpsRequest(url, options = {}, body = undefined, cookies = [], redirectCount = 0) {
32
+ if (redirectCount > 10) throw new Error('Too many redirects');
33
+
34
+ return new Promise((resolve, reject) => {
35
+ const urlObj = new URL(url);
36
+ const reqOptions = {
37
+ ...options,
38
+ hostname: urlObj.hostname,
39
+ path: urlObj.pathname + urlObj.search,
40
+ };
41
+
42
+ if (!reqOptions.headers) reqOptions.headers = {};
43
+ if (cookies.length > 0) reqOptions.headers['Cookie'] = cookies.join('; ');
44
+
45
+ const req = https.request(reqOptions, (res) => {
46
+ // collect Set-Cookie
47
+ const setCookie = res.headers['set-cookie'];
48
+ if (setCookie) {
49
+ setCookie.forEach(c => {
50
+ const name = c.split('=')[0];
51
+ const idx = cookies.findIndex(x => x.startsWith(name + '='));
52
+ if (idx >= 0) cookies[idx] = c.split(';')[0];
53
+ else cookies.push(c.split(';')[0]);
54
+ });
55
+ }
56
+
57
+ // follow redirect
58
+ if ([301, 302, 303].includes(res.statusCode) && res.headers.location) {
59
+ const redirectUrl = res.headers.location.startsWith('http')
60
+ ? res.headers.location
61
+ : `https://${urlObj.hostname}${res.headers.location}`;
62
+ return resolve(this.httpsRequest(redirectUrl, options, undefined, cookies, redirectCount + 1));
63
+ }
64
+
65
+ let data = '';
66
+ res.on('data', chunk => data += chunk);
67
+ res.on('end', () => resolve({ html: data, cookies, finalUrl: url }));
68
+ });
69
+
70
+ req.on('error', reject);
71
+ if (body) req.write(body);
72
+ req.end();
73
+ });
74
+ }
75
+
76
+ // --- Get login page ---
36
77
  async getLoginPage(authUrl) {
37
78
  console.log('Getting login page...');
38
- const resp = await this.axiosInstance.get(authUrl, {
79
+ const resp = await this.httpsRequest(authUrl, {
80
+ method: 'GET',
39
81
  headers: {
40
82
  'User-Agent': MOBILE_USER_AGENT,
41
83
  '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
84
+ }
45
85
  });
46
86
 
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');
87
+ const dom = new JSDOM(resp.html);
88
+ const form = dom.window.document.querySelector('form');
89
+ if (!form) throw new Error('Login form not found');
53
90
 
54
- const cookies = await this.jar.getCookieString(authUrl) || '';
55
- return { html, loginUrl: resp.request.res.responseUrl, csrf: csrfMatch[1], cookies };
91
+ return { formHtml: resp.html, cookies: resp.cookies, form };
56
92
  }
57
93
 
58
- async submitLogin(loginUrl, email, password, cookies) {
94
+ // --- Submit login ---
95
+ async submitLogin(form, email, password, cookies, loginPageUrl) {
59
96
  console.log('Submitting login credentials...');
60
97
 
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
-
98
+ // Collect all hidden inputs
75
99
  const formData = new URLSearchParams();
76
- for (const input of form.querySelectorAll('input')) {
100
+ form.querySelectorAll('input').forEach(input => {
77
101
  const name = input.getAttribute('name');
78
- const value = input.getAttribute('value') || '';
79
- formData.append(name, value);
80
- }
102
+ if (name) formData.append(name, input.getAttribute('value') || '');
103
+ });
81
104
 
82
- // 3. Nadpisujemy username i password
105
+ // Overwrite username/password
83
106
  formData.set('username', email);
84
107
  formData.set('password', password);
85
108
 
86
- const actionUrl = new URL(form.getAttribute('action'), loginUrl).toString();
109
+ const actionUrl = new URL(form.getAttribute('action'), loginPageUrl).toString();
110
+ const resp = await this.httpsRequest(actionUrl, {
111
+ method: 'POST',
112
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
113
+ }, formData.toString(), cookies);
87
114
 
88
- // 4. Wysyłamy POST do form action
89
- return this.submitFormPost(actionUrl, formData);
90
- }
115
+ // Try to extract code from redirect
116
+ const codeFromLocation = resp.finalUrl.match(/[?&]code=([^&]+)/);
117
+ if (codeFromLocation) return codeFromLocation[1];
91
118
 
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];
119
+ // Or from body (JS redirect or hidden form_post)
120
+ const codeFromBody = resp.html.match(/melcloudhome:\/\/[^"'\s]*[?&]code=([^&"'\s]+)/);
121
+ if (codeFromBody) return codeFromBody[1];
111
122
 
112
123
  throw new Error('No authorization code found after login');
113
124
  }
114
125
 
126
+ // --- Exchange code for tokens ---
115
127
  async exchangeCodeForTokens(code, codeVerifier) {
116
128
  console.log('Exchanging authorization code for tokens...');
117
129
 
@@ -120,28 +132,27 @@ class MelCloudHomeToken {
120
132
  code,
121
133
  redirect_uri: REDIRECT_URI,
122
134
  client_id: CLIENT_ID,
123
- code_verifier: codeVerifier
135
+ code_verifier: codeVerifier,
124
136
  });
125
137
 
126
- const resp = await this.axiosInstance.post(TOKEN_ENDPOINT, tokenData.toString(), {
138
+ const resp = await this.httpsRequest(TOKEN_ENDPOINT, {
139
+ method: 'POST',
127
140
  headers: {
128
141
  'Content-Type': 'application/x-www-form-urlencoded',
129
- 'Authorization': 'Basic aG9tZW1vYmlsZTo='
142
+ 'Content-Length': tokenData.toString().length,
143
+ 'Authorization': 'Basic aG9tZW1vYmlsZTo=', // base64(homemobile:)
130
144
  }
131
- });
132
-
133
- if (resp.data.error) throw new Error(`Token exchange failed: ${resp.data.error_description || resp.data.error}`);
145
+ }, tokenData.toString());
134
146
 
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
- };
147
+ const tokens = JSON.parse(resp.html);
148
+ if (tokens.error) throw new Error(`Token exchange failed: ${tokens.error_description || tokens.error}`);
149
+ return tokens;
141
150
  }
142
151
 
152
+ // --- Main flow ---
143
153
  async getTokensWithCredentials(email, password) {
144
154
  console.log('Obtaining OAuth tokens automatically...');
155
+
145
156
  const pkce = this.generatePKCE();
146
157
  const state = this.generateState();
147
158
 
@@ -154,10 +165,14 @@ class MelCloudHomeToken {
154
165
  authUrl.searchParams.set('code_challenge_method', 'S256');
155
166
  authUrl.searchParams.set('state', state);
156
167
 
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);
168
+ // 1️⃣ get login page
169
+ const { form, cookies } = await this.getLoginPage(authUrl.toString());
160
170
 
171
+ // 2️⃣ submit login
172
+ const authCode = await this.submitLogin(form, email, password, cookies, authUrl.toString());
173
+
174
+ // 3️⃣ exchange code for tokens
175
+ const tokens = await this.exchangeCodeForTokens(authCode, pkce.verifier);
161
176
  console.log('✓ Successfully obtained OAuth tokens');
162
177
  return tokens;
163
178
  }