homebridge-melcloud-control 4.0.0-beta.253 → 4.0.0-beta.255
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 +1 -1
- package/src/melcloud.js +1 -1
- package/src/melcloudhometoken.js +199 -41
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.255",
|
|
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
|
@@ -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.
|
|
180
|
+
this.tokens = await oauth.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;
|
package/src/melcloudhometoken.js
CHANGED
|
@@ -1,61 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Helper - Automatic Token Management (ES Module)
|
|
3
|
+
*/
|
|
4
|
+
|
|
1
5
|
import https from 'https';
|
|
2
|
-
import
|
|
6
|
+
import crypto from 'crypto';
|
|
3
7
|
|
|
4
|
-
const
|
|
8
|
+
const MOBILE_USER_AGENT = 'MonitorAndControl.App.Mobile/35 CFNetwork/3860.100.1 Darwin/25.0.0';
|
|
5
9
|
const CLIENT_ID = 'homemobile';
|
|
6
|
-
const REDIRECT_URI = 'melcloudhome://
|
|
10
|
+
const REDIRECT_URI = 'melcloudhome://';
|
|
7
11
|
const SCOPE = 'openid profile email offline_access IdentityServerApi';
|
|
12
|
+
const TOKEN_ENDPOINT = 'https://auth.melcloudhome.com/connect/token';
|
|
13
|
+
const AUTHORIZE_ENDPOINT = 'https://auth.melcloudhome.com/connect/authorize';
|
|
8
14
|
|
|
9
15
|
class MelCloudHomeToken {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
let data = '';
|
|
14
|
-
res.on('data', chunk => data += chunk);
|
|
15
|
-
res.on('end', () => resolve({ statusCode: res.statusCode, body: data }));
|
|
16
|
-
});
|
|
17
|
-
req.on('error', reject);
|
|
18
|
-
if (body) req.write(body);
|
|
19
|
-
req.end();
|
|
20
|
-
});
|
|
21
|
-
}
|
|
16
|
+
constructor(log) {
|
|
17
|
+
this.log = log;
|
|
18
|
+
}
|
|
22
19
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
console.log('Obtaining OAuth tokens using password grant...');
|
|
29
|
-
|
|
30
|
-
const params = new URLSearchParams({
|
|
31
|
-
grant_type: 'password',
|
|
32
|
-
client_id: CLIENT_ID,
|
|
33
|
-
username,
|
|
34
|
-
password,
|
|
35
|
-
scope: SCOPE,
|
|
36
|
-
});
|
|
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
|
+
}
|
|
37
25
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
};
|
|
26
|
+
generateState() {
|
|
27
|
+
return crypto.randomBytes(32).toString('hex');
|
|
28
|
+
}
|
|
42
29
|
|
|
43
|
-
|
|
30
|
+
httpsRequest(url, options, body, redirectCount = 0) {
|
|
31
|
+
if (redirectCount > 10) return Promise.reject(new Error('Too many redirects'));
|
|
44
32
|
|
|
45
|
-
|
|
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
|
+
}
|
|
46
45
|
|
|
47
|
-
|
|
46
|
+
let data = '';
|
|
47
|
+
res.on('data', chunk => data += chunk);
|
|
48
|
+
res.on('end', () => resolve(data));
|
|
49
|
+
});
|
|
48
50
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
+
});
|
|
52
77
|
}
|
|
53
78
|
|
|
54
|
-
|
|
55
|
-
|
|
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);
|
|
85
|
+
}
|
|
86
|
+
|
|
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'));
|
|
150
|
+
});
|
|
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;
|
|
56
212
|
}
|
|
213
|
+
}
|
|
57
214
|
}
|
|
58
215
|
|
|
216
|
+
|
|
59
217
|
export default MelCloudHomeToken;
|
|
60
218
|
|
|
61
219
|
|