homebridge-melcloud-control 4.0.0-beta.236 → 4.0.0-beta.238
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 +3 -2
- package/src/melcloud.js +17 -2
- package/src/melcloudhometoken.js +178 -0
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.238",
|
|
5
5
|
"description": "Homebridge plugin to control Mitsubishi Air Conditioner, Heat Pump and Energy Recovery Ventilation.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "grzegorz914",
|
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
"async-mqtt": "^2.6.3",
|
|
40
40
|
"axios": "^1.12.2",
|
|
41
41
|
"express": "^5.1.0",
|
|
42
|
-
"puppeteer": "^24.26.1"
|
|
42
|
+
"puppeteer": "^24.26.1",
|
|
43
|
+
"tough-cookie":"6.0.0"
|
|
43
44
|
},
|
|
44
45
|
"keywords": [
|
|
45
46
|
"homebridge",
|
package/src/melcloud.js
CHANGED
|
@@ -3,6 +3,7 @@ import { execSync } from 'child_process';
|
|
|
3
3
|
import puppeteer from 'puppeteer';
|
|
4
4
|
import axios from 'axios';
|
|
5
5
|
import EventEmitter from 'events';
|
|
6
|
+
import MelCloudHomeToken from './melcloudhometoken.js';
|
|
6
7
|
import ImpulseGenerator from './impulsegenerator.js';
|
|
7
8
|
import Functions from './functions.js';
|
|
8
9
|
import { ApiUrls, ApiUrlsHome } from './constants.js';
|
|
@@ -24,6 +25,12 @@ class MelCloud extends EventEmitter {
|
|
|
24
25
|
this.contextKey = '';
|
|
25
26
|
this.functions = new Functions();
|
|
26
27
|
|
|
28
|
+
this.axiosInstance = axios.create({
|
|
29
|
+
baseURL: 'https://app.melcloud.com/Mitsubishi.Wifi.Client',
|
|
30
|
+
headers: { 'User-Agent': 'MonitorAndControl.App.Mobile/35 CFNetwork/3860.100.1 Darwin/25.0.0' }
|
|
31
|
+
});
|
|
32
|
+
this.tokens = null;
|
|
33
|
+
|
|
27
34
|
this.loginData = {
|
|
28
35
|
Email: user,
|
|
29
36
|
Password: passwd,
|
|
@@ -167,6 +174,15 @@ class MelCloud extends EventEmitter {
|
|
|
167
174
|
}
|
|
168
175
|
}
|
|
169
176
|
|
|
177
|
+
// MELCloud Home
|
|
178
|
+
async connectToMelCloudHome() {
|
|
179
|
+
const oauth = new MelCloudHomeToken(this.log);
|
|
180
|
+
this.tokens = await oauth.getTokensWithCredentials(this.user, this.passwd);
|
|
181
|
+
this.emit('warn', `Token${JSON.stringify(this.tokens, null, 2)}`);
|
|
182
|
+
//this.axiosInstance.defaults.headers['Authorization'] = `Bearer ${this.tokens.accessToken}`;
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
170
186
|
async checkMelcloudHomeDevicesList(contextKey) {
|
|
171
187
|
try {
|
|
172
188
|
const axiosInstance = axios.create({
|
|
@@ -270,8 +286,7 @@ class MelCloud extends EventEmitter {
|
|
|
270
286
|
}
|
|
271
287
|
}
|
|
272
288
|
|
|
273
|
-
|
|
274
|
-
async connectToMelCloudHome(refresh = false) {
|
|
289
|
+
async connectToMelCloudHome1(refresh = false) {
|
|
275
290
|
if (this.logDebug) this.emit('debug', `Connecting to MELCloud Home`);
|
|
276
291
|
|
|
277
292
|
let browser;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// oauth-helper-axios.js
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
import { CookieJar } from 'tough-cookie';
|
|
4
|
+
import { wrapper } from 'axios-cookiejar-support';
|
|
5
|
+
import crypto from 'crypto';
|
|
6
|
+
|
|
7
|
+
const MOBILE_USER_AGENT = 'MonitorAndControl.App.Mobile/35 CFNetwork/3860.100.1 Darwin/25.0.0';
|
|
8
|
+
const CLIENT_ID = 'homemobile';
|
|
9
|
+
const REDIRECT_URI = 'melcloudhome://';
|
|
10
|
+
const SCOPE = 'openid profile email offline_access IdentityServerApi';
|
|
11
|
+
const TOKEN_ENDPOINT = 'https://auth.melcloudhome.com/connect/token';
|
|
12
|
+
const AUTHORIZE_ENDPOINT = 'https://auth.melcloudhome.com/connect/authorize';
|
|
13
|
+
|
|
14
|
+
export class MelCloudHomeToken {
|
|
15
|
+
constructor(log) {
|
|
16
|
+
this.log = 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
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
generatePKCE() {
|
|
27
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
28
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
29
|
+
return { verifier, challenge };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
generateState() {
|
|
33
|
+
return crypto.randomBytes(32).toString('hex');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async getLoginPage(authUrl) {
|
|
37
|
+
console.log('Getting login page...');
|
|
38
|
+
const resp = await this.axiosInstance.get(authUrl, {
|
|
39
|
+
headers: {
|
|
40
|
+
'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
|
|
44
|
+
});
|
|
45
|
+
|
|
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');
|
|
52
|
+
|
|
53
|
+
const cookies = await this.jar.getCookieString(authUrl) || '';
|
|
54
|
+
return { html, loginUrl: resp.request.res.responseUrl, csrf: csrfMatch[1], cookies };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async submitLogin(loginUrl, csrf, email, password, cookies) {
|
|
58
|
+
console.log('Submitting login credentials...');
|
|
59
|
+
|
|
60
|
+
const formData = new URLSearchParams({ _csrf: csrf, username: email, password: password });
|
|
61
|
+
|
|
62
|
+
const resp = await this.axiosInstance.post(loginUrl, formData.toString(), {
|
|
63
|
+
headers: {
|
|
64
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
65
|
+
'Cookie': cookies,
|
|
66
|
+
'Origin': 'https://live-melcloudhome.auth.eu-west-1.amazoncognito.com',
|
|
67
|
+
'Referer': loginUrl
|
|
68
|
+
},
|
|
69
|
+
maxRedirects: 0,
|
|
70
|
+
validateStatus: status => status >= 200 && status < 400
|
|
71
|
+
}).catch(err => {
|
|
72
|
+
if (err.response && err.response.status >= 300 && err.response.status < 400) return err.response;
|
|
73
|
+
throw err;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const locationHeader = resp.headers.location;
|
|
77
|
+
if (locationHeader && locationHeader.startsWith('melcloudhome://')) {
|
|
78
|
+
const codeMatch = locationHeader.match(/[?&]code=([^&]+)/);
|
|
79
|
+
if (codeMatch) return codeMatch[1];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const formCodeMatch = resp.data.match(/name="code"\s+value="([^"]+)"/);
|
|
83
|
+
const formStateMatch = resp.data.match(/name="state"\s+value="([^"]+)"/);
|
|
84
|
+
const formActionMatch = resp.data.match(/action="([^"]+)"/);
|
|
85
|
+
|
|
86
|
+
if (formCodeMatch && formStateMatch && formActionMatch) {
|
|
87
|
+
return this.submitFormPost(formActionMatch[1], formCodeMatch[1], formStateMatch[1]);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const bodyCodeMatch = resp.data.match(/melcloudhome:\/\/[^"'\s]*[?&]code=([^&"'\s]+)/);
|
|
91
|
+
if (bodyCodeMatch) return bodyCodeMatch[1];
|
|
92
|
+
|
|
93
|
+
throw new Error('No authorization code found after login');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async submitFormPost(actionUrl, code, state) {
|
|
97
|
+
const formData = new URLSearchParams({ code, state });
|
|
98
|
+
|
|
99
|
+
const resp = await this.axiosInstance.post(actionUrl, formData.toString(), {
|
|
100
|
+
headers: {
|
|
101
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
102
|
+
'Origin': 'https://live-melcloudhome.auth.eu-west-1.amazoncognito.com',
|
|
103
|
+
'Referer': actionUrl
|
|
104
|
+
},
|
|
105
|
+
maxRedirects: 0,
|
|
106
|
+
validateStatus: status => status >= 200 && status < 400
|
|
107
|
+
}).catch(err => {
|
|
108
|
+
if (err.response && err.response.status >= 300 && err.response.status < 400) return err.response;
|
|
109
|
+
throw err;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const locationHeader = resp.headers.location;
|
|
113
|
+
if (locationHeader && locationHeader.startsWith('melcloudhome://')) {
|
|
114
|
+
const codeMatch = locationHeader.match(/[?&]code=([^&]+)/);
|
|
115
|
+
if (codeMatch) return codeMatch[1];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const bodyCodeMatch = resp.data.match(/melcloudhome:\/\/[^"'\s]*[?&]code=([^&"'\s]+)/);
|
|
119
|
+
if (bodyCodeMatch) return bodyCodeMatch[1];
|
|
120
|
+
|
|
121
|
+
throw new Error('No authorization code found in form_post callback');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async exchangeCodeForTokens(code, codeVerifier) {
|
|
125
|
+
console.log('Exchanging authorization code for tokens...');
|
|
126
|
+
|
|
127
|
+
const tokenData = new URLSearchParams({
|
|
128
|
+
grant_type: 'authorization_code',
|
|
129
|
+
code,
|
|
130
|
+
redirect_uri: REDIRECT_URI,
|
|
131
|
+
client_id: CLIENT_ID,
|
|
132
|
+
code_verifier: codeVerifier
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const resp = await this.axiosInstance.post(TOKEN_ENDPOINT, tokenData.toString(), {
|
|
136
|
+
headers: {
|
|
137
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
138
|
+
'Authorization': 'Basic aG9tZW1vYmlsZTo='
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (resp.data.error) throw new Error(`Token exchange failed: ${resp.data.error_description || resp.data.error}`);
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
accessToken: resp.data.access_token,
|
|
146
|
+
refreshToken: resp.data.refresh_token,
|
|
147
|
+
expiresIn: resp.data.expires_in,
|
|
148
|
+
idToken: resp.data.id_token
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async getTokensWithCredentials(email, password) {
|
|
153
|
+
this.log.info('Obtaining OAuth tokens automatically...');
|
|
154
|
+
const pkce = this.generatePKCE();
|
|
155
|
+
const state = this.generateState();
|
|
156
|
+
|
|
157
|
+
const authUrl = new URL(AUTHORIZE_ENDPOINT);
|
|
158
|
+
authUrl.searchParams.set('client_id', CLIENT_ID);
|
|
159
|
+
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
|
|
160
|
+
authUrl.searchParams.set('response_type', 'code');
|
|
161
|
+
authUrl.searchParams.set('scope', SCOPE);
|
|
162
|
+
authUrl.searchParams.set('code_challenge', pkce.challenge);
|
|
163
|
+
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
164
|
+
authUrl.searchParams.set('state', state);
|
|
165
|
+
|
|
166
|
+
const loginPage = await this.getLoginPage(authUrl.toString());
|
|
167
|
+
const authCode = await this.submitLogin(loginPage.loginUrl, loginPage.csrf, email, password, loginPage.cookies);
|
|
168
|
+
const tokens = await this.exchangeCodeForTokens(authCode, pkce.verifier);
|
|
169
|
+
|
|
170
|
+
this.log.info('✓ Successfully obtained OAuth tokens');
|
|
171
|
+
return tokens;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = MelCloudHomeToken;
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
|