homebridge-melcloud-control 4.0.0-beta.256 → 4.0.0-beta.258
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 +4 -2
- package/src/melcloudhometoken.js +145 -195
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.
|
|
4
|
+
"version": "4.0.0-beta.258",
|
|
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,9 @@
|
|
|
40
40
|
"axios": "^1.12.2",
|
|
41
41
|
"express": "^5.1.0",
|
|
42
42
|
"puppeteer": "^24.26.1",
|
|
43
|
-
"
|
|
43
|
+
"axios-cookiejar-support": "^6.0.4",
|
|
44
|
+
"tough-cookie": "^6.0.0",
|
|
45
|
+
"cheerio": "^1.1.2"
|
|
44
46
|
},
|
|
45
47
|
"keywords": [
|
|
46
48
|
"homebridge",
|
package/src/melcloudhometoken.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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';
|
|
5
|
+
import cheerio from 'cheerio';
|
|
7
6
|
|
|
8
7
|
const MOBILE_USER_AGENT = 'MonitorAndControl.App.Mobile/35 CFNetwork/3860.100.1 Darwin/25.0.0';
|
|
9
8
|
const CLIENT_ID = 'homemobile';
|
|
@@ -13,207 +12,158 @@ 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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
15
|
+
constructor(log = console) {
|
|
16
|
+
this.log = log;
|
|
17
|
+
const jar = new CookieJar();
|
|
18
|
+
this.client = wrapper(axios.create({ jar, withCredentials: true }));
|
|
19
|
+
this.client.defaults.headers['User-Agent'] = MOBILE_USER_AGENT;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
generatePKCE() {
|
|
23
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
24
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
25
|
+
return { verifier, challenge };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
generateState() {
|
|
29
|
+
return crypto.randomBytes(32).toString('hex');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async getLoginPage(authUrl) {
|
|
33
|
+
console.log('Getting login page...');
|
|
34
|
+
const res = await this.client.get(authUrl, { maxRedirects: 10 });
|
|
35
|
+
const $ = cheerio.load(res.data);
|
|
36
|
+
const csrf = $('input[name="_csrf"]').attr('value');
|
|
37
|
+
if (!csrf) throw new Error('Could not extract CSRF token from login page');
|
|
38
|
+
console.log('CSRF token found:', csrf);
|
|
39
|
+
return { csrf, url: res.request.res.responseUrl };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async submitFormPost(actionUrl, code, state) {
|
|
43
|
+
console.log('Submitting form_post to callback URL:', actionUrl);
|
|
44
|
+
const form = new URLSearchParams({ code, state });
|
|
45
|
+
const res = await this.client.post(actionUrl, form.toString(), {
|
|
46
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
47
|
+
maxRedirects: 0,
|
|
48
|
+
validateStatus: (status) => status >= 200 && status < 400,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const location = res.headers.location;
|
|
52
|
+
if (location?.startsWith('melcloudhome://')) {
|
|
53
|
+
const codeMatch = location.match(/[?&]code=([^&]+)/);
|
|
54
|
+
if (codeMatch) {
|
|
55
|
+
console.log('Authorization code obtained from form_post redirect:', codeMatch[1]);
|
|
56
|
+
return codeMatch[1];
|
|
57
|
+
}
|
|
44
58
|
}
|
|
59
|
+
throw new Error('No authorization code found in form_post response');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async submitLogin(loginUrl, csrf, email, password) {
|
|
63
|
+
console.log('Submitting login credentials to:', loginUrl);
|
|
64
|
+
const form = new URLSearchParams({ _csrf: csrf, username: email, password });
|
|
65
|
+
const res = await this.client.post(loginUrl, form.toString(), {
|
|
66
|
+
headers: {
|
|
67
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
68
|
+
Referer: loginUrl,
|
|
69
|
+
},
|
|
70
|
+
maxRedirects: 0,
|
|
71
|
+
validateStatus: (status) => status >= 200 && status < 400,
|
|
72
|
+
});
|
|
45
73
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
});
|
|
74
|
+
console.log('Login POST status:', res.status);
|
|
75
|
+
|
|
76
|
+
// Spróbuj pobrać kod z nagłówka Location
|
|
77
|
+
const location = res.headers.location;
|
|
78
|
+
if (location?.startsWith('melcloudhome://')) {
|
|
79
|
+
const codeMatch = location.match(/[?&]code=([^&]+)/);
|
|
80
|
+
if (codeMatch) {
|
|
81
|
+
console.log('Authorization code obtained from Location header:', codeMatch[1]);
|
|
82
|
+
return codeMatch[1];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Spróbuj parsować HTML w poszukiwaniu form_post
|
|
87
|
+
const $ = cheerio.load(res.data);
|
|
88
|
+
const formElement = $('form');
|
|
89
|
+
if (formElement.length) {
|
|
90
|
+
const action = formElement.attr('action');
|
|
91
|
+
const code = $('input[name="code"]').attr('value');
|
|
92
|
+
const state = $('input[name="state"]').attr('value');
|
|
93
|
+
if (action && code && state) {
|
|
94
|
+
console.log('Found form_post. Action:', action, 'Code:', code, 'State:', state);
|
|
95
|
+
return this.submitFormPost(action, code, state);
|
|
96
|
+
}
|
|
77
97
|
}
|
|
78
98
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
:
|
|
83
|
-
|
|
84
|
-
.then(resolve).catch(reject);
|
|
99
|
+
// Spróbuj wyciągnąć kod z JS w ciele strony
|
|
100
|
+
const bodyCodeMatch = res.data.match(/melcloudhome:\/\/[^"'\s]*[?&]code=([^&"'\s]+)/);
|
|
101
|
+
if (bodyCodeMatch) {
|
|
102
|
+
console.log('Authorization code obtained from HTML/JS body:', bodyCodeMatch[1]);
|
|
103
|
+
return bodyCodeMatch[1];
|
|
85
104
|
}
|
|
86
105
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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'));
|
|
106
|
+
throw new Error('Authorization code not found in login response');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async exchangeCodeForTokens(code, codeVerifier) {
|
|
110
|
+
console.log('Exchanging authorization code for tokens...');
|
|
111
|
+
const form = new URLSearchParams({
|
|
112
|
+
grant_type: 'authorization_code',
|
|
113
|
+
code,
|
|
114
|
+
redirect_uri: REDIRECT_URI,
|
|
115
|
+
client_id: CLIENT_ID,
|
|
116
|
+
code_verifier: codeVerifier,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const res = await this.client.post(TOKEN_ENDPOINT, form.toString(), {
|
|
120
|
+
headers: {
|
|
121
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
122
|
+
Authorization: 'Basic aG9tZW1vYmlsZTo=',
|
|
123
|
+
},
|
|
150
124
|
});
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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;
|
|
125
|
+
|
|
126
|
+
if (res.data.error) throw new Error(`Token exchange failed: ${res.data.error_description || res.data.error}`);
|
|
127
|
+
|
|
128
|
+
console.log('Tokens obtained:', res.data);
|
|
129
|
+
return {
|
|
130
|
+
accessToken: res.data.access_token,
|
|
131
|
+
refreshToken: res.data.refresh_token,
|
|
132
|
+
expiresIn: res.data.expires_in,
|
|
133
|
+
idToken: res.data.id_token,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async getTokensWithCredentials(email, password) {
|
|
138
|
+
try {
|
|
139
|
+
console.log('Starting OAuth flow...');
|
|
140
|
+
const pkce = this.generatePKCE();
|
|
141
|
+
const state = this.generateState();
|
|
142
|
+
|
|
143
|
+
const authUrl = new URL(AUTHORIZE_ENDPOINT);
|
|
144
|
+
authUrl.searchParams.set('client_id', CLIENT_ID);
|
|
145
|
+
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
|
|
146
|
+
authUrl.searchParams.set('response_type', 'code');
|
|
147
|
+
authUrl.searchParams.set('scope', SCOPE);
|
|
148
|
+
authUrl.searchParams.set('code_challenge', pkce.challenge);
|
|
149
|
+
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
150
|
+
authUrl.searchParams.set('state', state);
|
|
151
|
+
|
|
152
|
+
const loginPage = await this.getLoginPage(authUrl.toString());
|
|
153
|
+
const authCode = await this.submitLogin(loginPage.url, loginPage.csrf, email, password);
|
|
154
|
+
const tokens = await this.exchangeCodeForTokens(authCode, pkce.verifier);
|
|
155
|
+
|
|
156
|
+
console.log('✓ Successfully obtained OAuth tokens');
|
|
157
|
+
return tokens;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
console.error('Failed to obtain OAuth tokens:', error);
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
212
162
|
}
|
|
213
|
-
}
|
|
214
163
|
}
|
|
215
164
|
|
|
216
165
|
|
|
166
|
+
|
|
217
167
|
export default MelCloudHomeToken;
|
|
218
168
|
|
|
219
169
|
|