homebridge-melcloud-control 4.10.0-beta.0 → 4.10.0
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/CHANGELOG.md +1 -1
- package/index.js +3 -3
- package/package.json +1 -1
- package/src/melcloud.js +2 -1
- package/src/melcloudata.js +0 -1
- package/src/melcloudatw.js +1 -2
- package/src/melclouderv.js +1 -2
- package/src/melcloudhome.js +160 -131
package/CHANGELOG.md
CHANGED
|
@@ -28,7 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
28
28
|
|
|
29
29
|
## Changes
|
|
30
30
|
|
|
31
|
-
- added web socket for real time data
|
|
31
|
+
- added web socket for real time data update with MELCloud Home
|
|
32
32
|
- cleanup
|
|
33
33
|
|
|
34
34
|
# [4.9.1] - (15.04.2026)
|
package/index.js
CHANGED
|
@@ -90,7 +90,7 @@ class MelCloudPlatform {
|
|
|
90
90
|
if (logLevel.warn) log.warn(`Unknown account type: ${account.type}.`);
|
|
91
91
|
return;
|
|
92
92
|
}
|
|
93
|
-
melCloudClass.on('success', (msg) =>
|
|
93
|
+
melCloudClass.on('success', (msg) => log.success(`${name}, ${msg}`))
|
|
94
94
|
.on('info', (msg) => log.info(`${name}, ${msg}`))
|
|
95
95
|
.on('debug', (msg) => log.info(`${name}, debug: ${msg}`))
|
|
96
96
|
.on('warn', (msg) => log.warn(`${name}, ${msg}`))
|
|
@@ -188,8 +188,8 @@ class MelCloudPlatform {
|
|
|
188
188
|
continue;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
deviceClass.on('devInfo', (info) =>
|
|
192
|
-
.on('success', (msg) =>
|
|
191
|
+
deviceClass.on('devInfo', (info) => log.info(info))
|
|
192
|
+
.on('success', (msg) => log.success(`${name}, ${deviceTypeString}, ${deviceName}, ${msg}`))
|
|
193
193
|
.on('info', (msg) => log.info(`${name}, ${deviceTypeString}, ${deviceName}, ${msg}`))
|
|
194
194
|
.on('debug', (msg) => log.info(`${name}, ${deviceTypeString}, ${deviceName}, debug: ${msg}`))
|
|
195
195
|
.on('warn', (msg) => log.warn(`${name}, ${deviceTypeString}, ${deviceName}, ${msg}`))
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"displayName": "MELCloud Control",
|
|
3
3
|
"name": "homebridge-melcloud-control",
|
|
4
|
-
"version": "4.10.0
|
|
4
|
+
"version": "4.10.0",
|
|
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
|
@@ -10,6 +10,7 @@ class MelCloud extends EventEmitter {
|
|
|
10
10
|
this.user = account.user;
|
|
11
11
|
this.passwd = account.passwd;
|
|
12
12
|
this.language = account.language;
|
|
13
|
+
this.logSuccess = account.log?.success;
|
|
13
14
|
this.logWarn = account.log?.warn;
|
|
14
15
|
this.logError = account.log?.error;
|
|
15
16
|
this.logDebug = account.log?.debug;
|
|
@@ -26,7 +27,7 @@ class MelCloud extends EventEmitter {
|
|
|
26
27
|
await this.checkDevicesList();
|
|
27
28
|
}))
|
|
28
29
|
.on('state', (state) => {
|
|
29
|
-
this.emit(state ? 'success' : 'warn', `Impulse generator ${state ? 'started' : 'stopped'}`);
|
|
30
|
+
if (this.logSuccess) this.emit(state ? 'success' : 'warn', `Impulse generator ${state ? 'started' : 'stopped'}`);
|
|
30
31
|
});
|
|
31
32
|
}
|
|
32
33
|
}
|
package/src/melcloudata.js
CHANGED
|
@@ -6,7 +6,6 @@ class MelCloudAta extends EventEmitter {
|
|
|
6
6
|
constructor(account, device, defaultTempsFile, melCloudClass) {
|
|
7
7
|
super();
|
|
8
8
|
this.accountTypeMelcloud = account.type === 'melcloud';
|
|
9
|
-
this.logSuccess = account.log?.success;
|
|
10
9
|
this.logWarn = account.log?.warn;
|
|
11
10
|
this.logError = account.log?.error;
|
|
12
11
|
this.logDebug = account.log?.debug;
|
package/src/melcloudatw.js
CHANGED
|
@@ -6,7 +6,6 @@ class MelCloudAtw extends EventEmitter {
|
|
|
6
6
|
constructor(account, device, defaultTempsFile, melCloudClass) {
|
|
7
7
|
super();
|
|
8
8
|
this.accountTypeMelCloud = account.type === 'melcloud';
|
|
9
|
-
this.logSuccess = account.log?.success;
|
|
10
9
|
this.logWarn = account.log?.warn;
|
|
11
10
|
this.logError = account.log?.error;
|
|
12
11
|
this.logDebug = account.log?.debug;
|
|
@@ -242,7 +241,7 @@ class MelCloudAtw extends EventEmitter {
|
|
|
242
241
|
case 'schedule':
|
|
243
242
|
payload = { enabled: payload.enabled };
|
|
244
243
|
method = 'PUT';
|
|
245
|
-
path = ApiUrls.Home.Put.
|
|
244
|
+
path = ApiUrls.Home.Put.ScheduleEnabled.replace('deviceid', deviceData.DeviceID);
|
|
246
245
|
deviceData.ScheduleEnabled = payload.enabled;
|
|
247
246
|
break;
|
|
248
247
|
case 'scene':
|
package/src/melclouderv.js
CHANGED
|
@@ -6,7 +6,6 @@ class MelCloudErv extends EventEmitter {
|
|
|
6
6
|
constructor(account, device, defaultTempsFile, melCloudClass) {
|
|
7
7
|
super();
|
|
8
8
|
this.accountTypeMelCloud = account.type === 'melcloud';
|
|
9
|
-
this.logSuccess = account.log?.success;
|
|
10
9
|
this.logWarn = account.log?.warn;
|
|
11
10
|
this.logError = account.log?.error;
|
|
12
11
|
this.logDebug = account.log?.debug;
|
|
@@ -245,7 +244,7 @@ class MelCloudErv extends EventEmitter {
|
|
|
245
244
|
case 'schedule':
|
|
246
245
|
payload = { enabled: payload.enabled };
|
|
247
246
|
method = 'PUT';
|
|
248
|
-
path = ApiUrls.Home.Put.
|
|
247
|
+
path = ApiUrls.Home.Put.ScheduleEnabled.replace('deviceid', deviceData.DeviceID);
|
|
249
248
|
deviceData.ScheduleEnabled = payload.enabled;
|
|
250
249
|
break;
|
|
251
250
|
case 'scene':
|
package/src/melcloudhome.js
CHANGED
|
@@ -31,15 +31,15 @@ class MelCloudHome extends EventEmitter {
|
|
|
31
31
|
this.pacer = new RequestPacer();
|
|
32
32
|
|
|
33
33
|
// Axios clients
|
|
34
|
-
this.authClient = null; // cookie-jar client
|
|
35
|
-
this.client = null; // API client
|
|
34
|
+
this.authClient = null; // cookie-jar client used only during the auth flow
|
|
35
|
+
this.client = null; // API client used for all post-login requests
|
|
36
36
|
|
|
37
37
|
// Token state
|
|
38
38
|
this.accessToken = null;
|
|
39
39
|
this.refreshToken = null;
|
|
40
|
-
this.tokenExpiry = 0; // Unix timestamp (
|
|
40
|
+
this.tokenExpiry = 0; // Unix timestamp (seconds)
|
|
41
41
|
|
|
42
|
-
//
|
|
42
|
+
// Flag preventing duplicate interceptor registration on re-login
|
|
43
43
|
this.interceptorsAttached = false;
|
|
44
44
|
|
|
45
45
|
// WebSocket state
|
|
@@ -48,8 +48,8 @@ class MelCloudHome extends EventEmitter {
|
|
|
48
48
|
this.connecting = false;
|
|
49
49
|
this.heartbeat = null;
|
|
50
50
|
this.reconnectTimer = null;
|
|
51
|
-
this.reconnectDelay = 5_000;
|
|
52
|
-
this.reconnectDelayMax = 300_000; // 5
|
|
51
|
+
this.reconnectDelay = 5_000; // ms, grows exponentially up to reconnectDelayMax
|
|
52
|
+
this.reconnectDelayMax = 300_000; // 5 minutes
|
|
53
53
|
|
|
54
54
|
if (pluginStart) {
|
|
55
55
|
this.impulseGenerator = new ImpulseGenerator()
|
|
@@ -64,17 +64,19 @@ class MelCloudHome extends EventEmitter {
|
|
|
64
64
|
|
|
65
65
|
// ── WebSocket ─────────────────────────────────────────────────────────────
|
|
66
66
|
|
|
67
|
+
// Resets all WebSocket state and clears the heartbeat interval.
|
|
67
68
|
cleanupSocket() {
|
|
68
69
|
if (this.heartbeat) {
|
|
69
70
|
clearInterval(this.heartbeat);
|
|
70
71
|
this.heartbeat = null;
|
|
71
72
|
}
|
|
73
|
+
this.socket = null;
|
|
72
74
|
this.socketConnected = false;
|
|
73
75
|
this.connecting = false;
|
|
74
|
-
this.socket = null;
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
//
|
|
78
|
+
// Opens a WebSocket connection using the user ID from /api/user/context as the hash.
|
|
79
|
+
// Called automatically after a successful login and on every reconnect attempt.
|
|
78
80
|
async connectSocket() {
|
|
79
81
|
if (this.connecting || this.socketConnected) return;
|
|
80
82
|
this.connecting = true;
|
|
@@ -82,9 +84,10 @@ class MelCloudHome extends EventEmitter {
|
|
|
82
84
|
let hash;
|
|
83
85
|
try {
|
|
84
86
|
const resp = await this.client.get(ApiUrls.Home.Get.Context);
|
|
85
|
-
hash = resp.data
|
|
87
|
+
hash = resp.data?.id ?? null;
|
|
88
|
+
if (!hash) throw new Error('id field missing in context response');
|
|
86
89
|
} catch (err) {
|
|
87
|
-
if (this.logError) this.emit('error', `
|
|
90
|
+
if (this.logError) this.emit('error', `WebSocket: cannot get hash: ${err.message}`);
|
|
88
91
|
this.connecting = false;
|
|
89
92
|
this.scheduleReconnect();
|
|
90
93
|
return;
|
|
@@ -97,68 +100,74 @@ class MelCloudHome extends EventEmitter {
|
|
|
97
100
|
'Cache-Control': 'no-cache',
|
|
98
101
|
};
|
|
99
102
|
|
|
100
|
-
if (this.logDebug) this.emit('debug', `
|
|
103
|
+
if (this.logDebug) this.emit('debug', `WebSocket connecting: ${url.slice(0, 60)}...`);
|
|
101
104
|
|
|
102
105
|
try {
|
|
103
106
|
const ws = new WebSocket(url, { headers });
|
|
104
107
|
this.socket = ws;
|
|
105
108
|
|
|
106
109
|
ws.on('error', (error) => {
|
|
107
|
-
if (this.logError) this.emit('error', `
|
|
108
|
-
try { ws.close(); } catch { /*
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
110
|
+
if (this.logError) this.emit('error', `WebSocket error: ${error.message}`);
|
|
111
|
+
try { ws.close(); } catch { /* ignore if already closed */ }
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
ws.on('close', () => {
|
|
115
|
+
if (this.logDebug) this.emit('debug', 'WebSocket closed');
|
|
116
|
+
this.cleanupSocket();
|
|
117
|
+
this.scheduleReconnect();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
ws.on('open', () => {
|
|
121
|
+
this.socketConnected = true;
|
|
122
|
+
this.connecting = false;
|
|
123
|
+
this.reconnectDelay = 5_000; // reset backoff on successful connection
|
|
124
|
+
if (this.reconnectTimer) {
|
|
125
|
+
clearTimeout(this.reconnectTimer);
|
|
126
|
+
this.reconnectTimer = null;
|
|
127
|
+
}
|
|
128
|
+
if (this.logSuccess) this.emit('success', 'WebSocket connected');
|
|
129
|
+
|
|
130
|
+
// Send a ping every 30 s to keep the connection alive
|
|
131
|
+
this.heartbeat = setInterval(() => {
|
|
132
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
133
|
+
if (this.logDebug) this.emit('debug', 'WebSocket heartbeat sent');
|
|
134
|
+
ws.ping();
|
|
122
135
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
// Heartbeat co 30s
|
|
126
|
-
this.heartbeat = setInterval(() => {
|
|
127
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
128
|
-
if (this.logDebug) this.emit('debug', 'Web socket send heartbeat');
|
|
129
|
-
ws.ping();
|
|
130
|
-
}
|
|
131
|
-
}, 30_000);
|
|
132
|
-
})
|
|
133
|
-
.on('pong', () => {
|
|
134
|
-
if (this.logDebug) this.emit('debug', 'Web socket received heartbeat');
|
|
135
|
-
})
|
|
136
|
-
.on('message', (message) => {
|
|
137
|
-
try {
|
|
138
|
-
const parsedMessage = JSON.parse(message);
|
|
139
|
-
if (this.logDebug) this.emit('debug', `Web socket incoming message: ${JSON.stringify(parsedMessage, null, 2)}`);
|
|
136
|
+
}, 30_000);
|
|
137
|
+
});
|
|
140
138
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
139
|
+
ws.on('pong', () => {
|
|
140
|
+
if (this.logDebug) this.emit('debug', 'WebSocket heartbeat received');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
ws.on('message', (message) => {
|
|
144
|
+
try {
|
|
145
|
+
const parsed = JSON.parse(message);
|
|
146
|
+
const messageData = parsed?.[0]?.Data;
|
|
147
|
+
|
|
148
|
+
if (this.logDebug) this.emit('debug', `WebSocket message: ${JSON.stringify(parsed, null, 2)}`);
|
|
149
|
+
|
|
150
|
+
// Ignore empty payloads and server-side auth errors
|
|
151
|
+
if (!messageData || parsed.message === 'Forbidden') return;
|
|
152
|
+
|
|
153
|
+
this.emit(messageData.id, 'ws', parsed[0]);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
if (this.logError) this.emit('error', `WebSocket message parse error: ${err.message}`);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
144
158
|
|
|
145
|
-
this.emit(messageData.id, 'ws', parsedMessage[0]);
|
|
146
|
-
} catch (err) {
|
|
147
|
-
if (this.logError) this.emit('error', `Web socket message parse error: ${err.message}`);
|
|
148
|
-
}
|
|
149
|
-
});
|
|
150
159
|
} catch (error) {
|
|
151
|
-
if (this.logError) this.emit('error', `
|
|
160
|
+
if (this.logError) this.emit('error', `WebSocket connection failed: ${error.message}`);
|
|
152
161
|
this.cleanupSocket();
|
|
153
162
|
this.scheduleReconnect();
|
|
154
163
|
}
|
|
155
164
|
}
|
|
156
165
|
|
|
157
|
-
//
|
|
166
|
+
// Schedules a reconnect attempt using exponential backoff (5 s → 10 s → … → 5 min).
|
|
158
167
|
scheduleReconnect() {
|
|
159
|
-
if (this.reconnectTimer) return; //
|
|
168
|
+
if (this.reconnectTimer) return; // already scheduled
|
|
160
169
|
|
|
161
|
-
if (this.logDebug) this.emit('debug', `
|
|
170
|
+
if (this.logDebug) this.emit('debug', `WebSocket reconnecting in ${this.reconnectDelay / 1000} s...`);
|
|
162
171
|
|
|
163
172
|
this.reconnectTimer = setTimeout(async () => {
|
|
164
173
|
this.reconnectTimer = null;
|
|
@@ -170,6 +179,7 @@ class MelCloudHome extends EventEmitter {
|
|
|
170
179
|
|
|
171
180
|
// ── Utils ─────────────────────────────────────────────────────────────────
|
|
172
181
|
|
|
182
|
+
// Recursively capitalizes the first letter of every object key.
|
|
173
183
|
capitalizeKeysDeep(obj) {
|
|
174
184
|
if (Array.isArray(obj)) return obj.map(item => this.capitalizeKeysDeep(item));
|
|
175
185
|
if (obj && typeof obj === 'object') {
|
|
@@ -185,6 +195,7 @@ class MelCloudHome extends EventEmitter {
|
|
|
185
195
|
|
|
186
196
|
// ── Token state ───────────────────────────────────────────────────────────
|
|
187
197
|
|
|
198
|
+
// Returns true when the access token is absent or expires within 60 seconds.
|
|
188
199
|
isTokenExpired() {
|
|
189
200
|
if (!this.accessToken) return true;
|
|
190
201
|
return Date.now() / 1000 >= this.tokenExpiry - 60;
|
|
@@ -192,11 +203,12 @@ class MelCloudHome extends EventEmitter {
|
|
|
192
203
|
|
|
193
204
|
// ── Axios clients ─────────────────────────────────────────────────────────
|
|
194
205
|
|
|
206
|
+
// Returns (creating if needed) the cookie-jar client used during the OAuth flow.
|
|
195
207
|
ensureAuthClient() {
|
|
196
208
|
if (this.authClient) return this.authClient;
|
|
197
209
|
|
|
198
210
|
const jar = new CookieJar();
|
|
199
|
-
|
|
211
|
+
this.authClient = wrapper(
|
|
200
212
|
axios.create({
|
|
201
213
|
jar,
|
|
202
214
|
timeout: 30_000,
|
|
@@ -205,14 +217,14 @@ class MelCloudHome extends EventEmitter {
|
|
|
205
217
|
'User-Agent': ApiUrls.Home.UserAgent,
|
|
206
218
|
},
|
|
207
219
|
maxRedirects: 5,
|
|
208
|
-
validateStatus: () => true,
|
|
220
|
+
validateStatus: () => true, // handle all status codes manually
|
|
209
221
|
})
|
|
210
222
|
);
|
|
211
223
|
|
|
212
|
-
this.authClient
|
|
213
|
-
return instance;
|
|
224
|
+
return this.authClient;
|
|
214
225
|
}
|
|
215
226
|
|
|
227
|
+
// Returns (creating if needed) the API client used for all post-login requests.
|
|
216
228
|
ensureClient() {
|
|
217
229
|
if (this.client) return this.client;
|
|
218
230
|
|
|
@@ -228,7 +240,7 @@ class MelCloudHome extends EventEmitter {
|
|
|
228
240
|
return this.client;
|
|
229
241
|
}
|
|
230
242
|
|
|
231
|
-
// ── Pacer
|
|
243
|
+
// ── Pacer ─────────────────────────────────────────────────────────────────
|
|
232
244
|
|
|
233
245
|
pace(fn) {
|
|
234
246
|
return this.pacer.run(fn);
|
|
@@ -244,6 +256,7 @@ class MelCloudHome extends EventEmitter {
|
|
|
244
256
|
|
|
245
257
|
// ── CSRF token ────────────────────────────────────────────────────────────
|
|
246
258
|
|
|
259
|
+
// Extracts the _csrf token value from the Cognito login page HTML.
|
|
247
260
|
extractCsrfToken(html) {
|
|
248
261
|
return (
|
|
249
262
|
/<input[^>]+name="_csrf"[^>]+value="([^"]+)"/.exec(html)?.[1] ??
|
|
@@ -253,8 +266,9 @@ class MelCloudHome extends EventEmitter {
|
|
|
253
266
|
);
|
|
254
267
|
}
|
|
255
268
|
|
|
256
|
-
// ──
|
|
269
|
+
// ── OAuth helpers ─────────────────────────────────────────────────────────
|
|
257
270
|
|
|
271
|
+
// Follows the /connect/authorize/callback redirect chain and returns the auth code.
|
|
258
272
|
async followCallbackForCode(client, callbackQs) {
|
|
259
273
|
const qs = callbackQs.replace(/&/g, '&');
|
|
260
274
|
const callbackUrl = `${ApiUrls.Home.AuthBase}/connect/authorize/callback?${qs}`;
|
|
@@ -272,10 +286,9 @@ class MelCloudHome extends EventEmitter {
|
|
|
272
286
|
if (m) return m[1];
|
|
273
287
|
}
|
|
274
288
|
|
|
275
|
-
if (!location || location === '/')
|
|
276
|
-
throw new Error('Callback returned empty or root redirect');
|
|
289
|
+
if (!location || location === '/') throw new Error('Callback returned empty or root redirect');
|
|
277
290
|
|
|
278
|
-
//
|
|
291
|
+
// One additional hop if needed
|
|
279
292
|
const redirectUrl = location.startsWith('http')
|
|
280
293
|
? location
|
|
281
294
|
: `${ApiUrls.Home.AuthBase}${location}`;
|
|
@@ -293,8 +306,7 @@ class MelCloudHome extends EventEmitter {
|
|
|
293
306
|
return m[1];
|
|
294
307
|
}
|
|
295
308
|
|
|
296
|
-
//
|
|
297
|
-
|
|
309
|
+
// Exchanges an authorization code for access and refresh tokens.
|
|
298
310
|
async exchangeCodeForTokens(client, authCode, codeVerifier) {
|
|
299
311
|
if (this.logDebug) this.emit('debug', 'Step 6: Token exchange');
|
|
300
312
|
|
|
@@ -325,11 +337,11 @@ class MelCloudHome extends EventEmitter {
|
|
|
325
337
|
|
|
326
338
|
// ── Token refresh ─────────────────────────────────────────────────────────
|
|
327
339
|
|
|
340
|
+
// Uses the stored refresh token to obtain a new access token.
|
|
328
341
|
async refreshAccessToken() {
|
|
329
342
|
if (!this.refreshToken) throw new Error('No refresh token available');
|
|
330
343
|
|
|
331
344
|
const client = this.ensureAuthClient();
|
|
332
|
-
|
|
333
345
|
const resp = await this.pace(() =>
|
|
334
346
|
client.post(
|
|
335
347
|
`${ApiUrls.Home.AuthBase}/connect/token`,
|
|
@@ -354,57 +366,66 @@ class MelCloudHome extends EventEmitter {
|
|
|
354
366
|
return true;
|
|
355
367
|
}
|
|
356
368
|
|
|
357
|
-
//
|
|
358
|
-
|
|
369
|
+
// Attempts a token refresh; falls back to a full re-login if the refresh token is
|
|
370
|
+
// missing or rejected. A single shared Promise prevents concurrent refresh races.
|
|
359
371
|
async refreshOrRelogin() {
|
|
360
|
-
if (this.
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
372
|
+
if (this.refreshPromise) return this.refreshPromise;
|
|
373
|
+
|
|
374
|
+
this.refreshPromise = (async () => {
|
|
375
|
+
if (this.refreshToken) {
|
|
376
|
+
try {
|
|
377
|
+
await this.refreshAccessToken();
|
|
378
|
+
if (this.logDebug) this.emit('debug', 'Token refreshed successfully');
|
|
379
|
+
return;
|
|
380
|
+
} catch (err) {
|
|
381
|
+
if (this.logDebug) this.emit('debug', `Refresh token rejected (${err.message}), falling back to full re-login`);
|
|
382
|
+
}
|
|
367
383
|
}
|
|
368
|
-
}
|
|
369
384
|
|
|
370
|
-
|
|
371
|
-
|
|
385
|
+
if (this.logDebug) this.emit('debug', 'Performing full re-login');
|
|
386
|
+
await this.connect();
|
|
387
|
+
})().finally(() => {
|
|
388
|
+
this.refreshPromise = null;
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
return this.refreshPromise;
|
|
372
392
|
}
|
|
373
393
|
|
|
374
|
-
// ──
|
|
394
|
+
// ── Token interceptors ────────────────────────────────────────────────────
|
|
375
395
|
|
|
396
|
+
// Attaches request and response interceptors to the API client.
|
|
397
|
+
// Safe to call multiple times — interceptors are registered only once.
|
|
376
398
|
attachTokenInterceptors() {
|
|
377
399
|
if (this.interceptorsAttached) return;
|
|
378
400
|
this.interceptorsAttached = true;
|
|
379
401
|
|
|
380
402
|
const apiClient = this.ensureClient();
|
|
381
403
|
|
|
382
|
-
//
|
|
383
|
-
//
|
|
404
|
+
// Inject a fresh Authorization header before every request.
|
|
405
|
+
// If the token is expired, refresh it first.
|
|
384
406
|
apiClient.interceptors.request.use(async (config) => {
|
|
385
407
|
if (this.isTokenExpired()) {
|
|
386
|
-
if (this.logDebug) this.emit('debug', 'Token expired
|
|
408
|
+
if (this.logDebug) this.emit('debug', 'Token expired — refreshing before request');
|
|
387
409
|
await this.refreshOrRelogin();
|
|
388
410
|
}
|
|
389
411
|
config.headers['Authorization'] = `Bearer ${this.accessToken}`;
|
|
390
412
|
return config;
|
|
391
413
|
});
|
|
392
414
|
|
|
393
|
-
//
|
|
394
|
-
// (np. token odwołany po stronie serwera). Ponawia request dokładnie raz.
|
|
415
|
+
// On 401, refresh the token and retry the original request exactly once.
|
|
395
416
|
apiClient.interceptors.response.use(
|
|
396
417
|
response => response,
|
|
397
418
|
async (error) => {
|
|
398
|
-
const
|
|
419
|
+
const original = error.config;
|
|
399
420
|
|
|
400
|
-
if (error.response?.status === 401 && !
|
|
401
|
-
|
|
402
|
-
if (this.logDebug) this.emit('debug', 'Got 401 — refreshing token and retrying
|
|
421
|
+
if (error.response?.status === 401 && !original.retried) {
|
|
422
|
+
original.retried = true;
|
|
423
|
+
if (this.logDebug) this.emit('debug', 'Got 401 — refreshing token and retrying');
|
|
403
424
|
|
|
404
425
|
try {
|
|
405
426
|
await this.refreshOrRelogin();
|
|
406
|
-
|
|
407
|
-
return apiClient(
|
|
427
|
+
original.headers['Authorization'] = `Bearer ${this.accessToken}`;
|
|
428
|
+
return apiClient(original);
|
|
408
429
|
} catch (refreshError) {
|
|
409
430
|
this.emit('error', `Token refresh failed: ${refreshError.message}`);
|
|
410
431
|
return Promise.reject(refreshError);
|
|
@@ -416,19 +437,19 @@ class MelCloudHome extends EventEmitter {
|
|
|
416
437
|
);
|
|
417
438
|
}
|
|
418
439
|
|
|
419
|
-
// ──
|
|
440
|
+
// ── Post-login setup ──────────────────────────────────────────────────────
|
|
420
441
|
|
|
442
|
+
// Finalises the connect flow: sets up the API client, attaches interceptors,
|
|
443
|
+
// emits the 'client' event and opens the WebSocket connection.
|
|
421
444
|
async buildConnectInfo(connectInfo, exchangeRes) {
|
|
422
445
|
if (exchangeRes) {
|
|
423
|
-
// ensureClient() tworzy client jeśli nie istnieje.
|
|
424
|
-
// attachTokenInterceptors() dodaje interceptory tylko przy pierwszym wywołaniu.
|
|
425
446
|
this.ensureClient();
|
|
426
447
|
this.attachTokenInterceptors();
|
|
427
448
|
|
|
428
449
|
if (this.pluginStart) {
|
|
429
450
|
this.emit('client', this.client);
|
|
430
451
|
await this.connectSocket().catch(err => {
|
|
431
|
-
if (this.logError) this.emit('error', `
|
|
452
|
+
if (this.logError) this.emit('error', `WebSocket initial connect failed: ${err.message}`);
|
|
432
453
|
});
|
|
433
454
|
}
|
|
434
455
|
}
|
|
@@ -441,17 +462,23 @@ class MelCloudHome extends EventEmitter {
|
|
|
441
462
|
|
|
442
463
|
// ── Connect ───────────────────────────────────────────────────────────────
|
|
443
464
|
|
|
465
|
+
// Full OAuth 2.0 PKCE login flow:
|
|
466
|
+
// Step 1 — Pushed Authorization Request (PAR)
|
|
467
|
+
// Step 2 — Authorize redirect → Cognito login page (or fast-path if session exists)
|
|
468
|
+
// Step 3 — POST credentials to Cognito (maxRedirects: 0 to intercept form_post)
|
|
469
|
+
// Step 4 — POST Cognito callback params to IdentityServer /signin-oidc-meu
|
|
470
|
+
// Step 5 — Follow redirect chain until the auth code is found
|
|
471
|
+
// Step 6 — Exchange auth code for access + refresh tokens
|
|
444
472
|
async connect() {
|
|
445
473
|
if (this.logDebug) this.emit('debug', 'Connecting to MELCloud Home');
|
|
446
474
|
|
|
447
475
|
try {
|
|
448
476
|
const connectInfo = { State: false, Status: '', Account: {}, UseFahrenheit: false };
|
|
449
|
-
|
|
450
477
|
const client = this.ensureAuthClient();
|
|
451
478
|
const { verifier: codeVerifier, challenge: codeChallenge } = this.generatePkce();
|
|
452
479
|
const state = crypto.randomBytes(16).toString('base64url');
|
|
453
480
|
|
|
454
|
-
// ── Step 1: PAR
|
|
481
|
+
// ── Step 1: PAR ───────────────────────────────────────────────────
|
|
455
482
|
if (this.logDebug) this.emit('debug', 'Step 1: PAR request');
|
|
456
483
|
|
|
457
484
|
const parResp = await this.pace(() =>
|
|
@@ -498,18 +525,16 @@ class MelCloudHome extends EventEmitter {
|
|
|
498
525
|
|
|
499
526
|
const finalUrl = authResp.request?.res?.responseUrl ?? authorizeUrl;
|
|
500
527
|
const parsed = new URL(finalUrl);
|
|
501
|
-
const body = typeof authResp.data === 'string'
|
|
502
|
-
? authResp.data
|
|
503
|
-
: JSON.stringify(authResp.data);
|
|
528
|
+
const body = typeof authResp.data === 'string' ? authResp.data : JSON.stringify(authResp.data);
|
|
504
529
|
|
|
505
530
|
if (parsed.hostname?.endsWith(ApiUrls.Home.CognitoDomainSuffix) && parsed.pathname.includes('/login')) {
|
|
506
|
-
// Happy path:
|
|
531
|
+
// Happy path: landed on the Cognito login page
|
|
507
532
|
csrfToken = this.extractCsrfToken(body);
|
|
508
533
|
if (!csrfToken) throw new Error('Failed to extract CSRF token from Cognito login page');
|
|
509
534
|
cognitoLoginUrl = finalUrl;
|
|
510
535
|
if (this.logDebug) this.emit('debug', 'Cognito login page OK');
|
|
511
536
|
} else {
|
|
512
|
-
// Fast path:
|
|
537
|
+
// Fast path: existing IdentityServer session — auth code available immediately
|
|
513
538
|
const codeMatch = /code=([^&"' ]+)/.exec(finalUrl) || /code=([^&"' ]+)/.exec(body);
|
|
514
539
|
if (codeMatch) {
|
|
515
540
|
authCode = codeMatch[1];
|
|
@@ -525,20 +550,21 @@ class MelCloudHome extends EventEmitter {
|
|
|
525
550
|
}
|
|
526
551
|
}
|
|
527
552
|
|
|
528
|
-
//
|
|
553
|
+
// Skip credential submission when we already have a code
|
|
529
554
|
if (authCode) {
|
|
530
555
|
if (this.logDebug) this.emit('debug', 'Re-login with existing session (skipping credentials)');
|
|
531
556
|
const exchangeRes = await this.exchangeCodeForTokens(client, authCode, codeVerifier);
|
|
532
557
|
return await this.buildConnectInfo(connectInfo, exchangeRes);
|
|
533
558
|
}
|
|
534
559
|
|
|
535
|
-
// ── Step 3:
|
|
560
|
+
// ── Step 3: Submit credentials to Cognito ─────────────────────────
|
|
561
|
+
// maxRedirects: 0 — Cognito uses response_mode=form_post, so after a
|
|
562
|
+
// successful login it POSTs back to IdentityServer (/signin-oidc-meu).
|
|
563
|
+
// We intercept the 302 before axios follows it to avoid a 500 from Kestrel.
|
|
536
564
|
if (this.logDebug) this.emit('debug', 'Step 3: Submit credentials to Cognito');
|
|
537
565
|
|
|
538
566
|
const cognitoHostname = new URL(cognitoLoginUrl).hostname;
|
|
539
567
|
|
|
540
|
-
// maxRedirects: 0 — Cognito używa response_mode=form_post.
|
|
541
|
-
// Przechwytujemy 302 zanim axios podąży za nim do IdentityServera (→ 500).
|
|
542
568
|
const credResp = await this.pace(() =>
|
|
543
569
|
client.post(
|
|
544
570
|
cognitoLoginUrl,
|
|
@@ -565,13 +591,14 @@ class MelCloudHome extends EventEmitter {
|
|
|
565
591
|
this.emit('debug', `Step 3 response location: ${credResp.headers?.location ?? '(none)'}`);
|
|
566
592
|
}
|
|
567
593
|
|
|
568
|
-
//
|
|
594
|
+
// HTTP 200 means Cognito returned the login page again → wrong password
|
|
569
595
|
if (credResp.status === 200) throw new Error('Authentication failed: Invalid username or password');
|
|
570
596
|
if (credResp.status >= 500) throw new Error(`Cognito server error: HTTP ${credResp.status}`);
|
|
571
597
|
|
|
572
|
-
// ── Step 4: POST
|
|
573
|
-
// Cognito
|
|
574
|
-
//
|
|
598
|
+
// ── Step 4: POST Cognito callback params to IdentityServer ─────────
|
|
599
|
+
// Cognito normally POSTs code+state to /signin-oidc-meu (form_post).
|
|
600
|
+
// We received a 302 with those params in the query string, so we replay
|
|
601
|
+
// them as a POST body — exactly as Cognito would have done.
|
|
575
602
|
if (this.logDebug) this.emit('debug', 'Step 4: Follow Cognito → IdentityServer redirect');
|
|
576
603
|
|
|
577
604
|
const cognitoRedirectLocation = credResp.headers?.location ?? '';
|
|
@@ -600,9 +627,9 @@ class MelCloudHome extends EventEmitter {
|
|
|
600
627
|
this.emit('debug', `Step 4 signin location: ${signinResp.headers?.location ?? '(none)'}`);
|
|
601
628
|
}
|
|
602
629
|
|
|
603
|
-
// ── Step 5:
|
|
604
|
-
// IdentityServer
|
|
605
|
-
//
|
|
630
|
+
// ── Step 5: Follow redirect chain until the auth code is found ────
|
|
631
|
+
// IdentityServer redirects through several hops:
|
|
632
|
+
// /ExternalLogin/Callback → /connect/authorize/callback → melcloudhome://
|
|
606
633
|
if (this.logDebug) this.emit('debug', 'Step 5: Following redirect chain to auth code');
|
|
607
634
|
|
|
608
635
|
let currentResp = signinResp;
|
|
@@ -615,13 +642,13 @@ class MelCloudHome extends EventEmitter {
|
|
|
615
642
|
|
|
616
643
|
if (this.logDebug) this.emit('debug', `Step 5 hop ${hop}: status=${hopStatus} location=${hopLocation || '(none)'}`);
|
|
617
644
|
|
|
618
|
-
// A:
|
|
645
|
+
// A: custom scheme redirect carrying the auth code
|
|
619
646
|
if (hopLocation.startsWith('melcloudhome://')) {
|
|
620
647
|
const m = /code=([^&"' ]+)/.exec(hopLocation);
|
|
621
648
|
if (m) { authCode = m[1]; break; }
|
|
622
649
|
}
|
|
623
650
|
|
|
624
|
-
// B:
|
|
651
|
+
// B: IdentityServer authorize callback — delegate to helper
|
|
625
652
|
const cbMatch = /\/connect\/authorize\/callback\?([^"' ]+)/.exec(hopLocation)
|
|
626
653
|
|| /\/connect\/authorize\/callback\?([^"' ]+)/.exec(hopBody);
|
|
627
654
|
if (cbMatch) {
|
|
@@ -630,15 +657,15 @@ class MelCloudHome extends EventEmitter {
|
|
|
630
657
|
break;
|
|
631
658
|
}
|
|
632
659
|
|
|
633
|
-
// C: code
|
|
660
|
+
// C: auth code directly in the Location header
|
|
634
661
|
const codeInLocation = /code=([^&"' ]+)/.exec(hopLocation);
|
|
635
662
|
if (codeInLocation) { authCode = codeInLocation[1]; break; }
|
|
636
663
|
|
|
637
|
-
// D: code
|
|
664
|
+
// D: auth code in the response body
|
|
638
665
|
const codeInBody = /code=([^&"' ]+)/.exec(hopBody);
|
|
639
666
|
if (codeInBody) { authCode = codeInBody[1]; break; }
|
|
640
667
|
|
|
641
|
-
//
|
|
668
|
+
// Standard redirect — follow the next hop
|
|
642
669
|
if ((hopStatus === 301 || hopStatus === 302 || hopStatus === 303) && hopLocation) {
|
|
643
670
|
const nextUrl = hopLocation.startsWith('http')
|
|
644
671
|
? hopLocation
|
|
@@ -660,7 +687,7 @@ class MelCloudHome extends EventEmitter {
|
|
|
660
687
|
|
|
661
688
|
if (this.logDebug) this.emit('debug', `Got auth code: ${authCode.slice(0, 20)}...`);
|
|
662
689
|
|
|
663
|
-
// ── Step 6:
|
|
690
|
+
// ── Step 6: Exchange auth code for tokens ─────────────────────────
|
|
664
691
|
const exchangeRes = await this.exchangeCodeForTokens(client, authCode, codeVerifier);
|
|
665
692
|
return await this.buildConnectInfo(connectInfo, exchangeRes);
|
|
666
693
|
|
|
@@ -669,23 +696,23 @@ class MelCloudHome extends EventEmitter {
|
|
|
669
696
|
}
|
|
670
697
|
}
|
|
671
698
|
|
|
672
|
-
// ── Scenes
|
|
699
|
+
// ── Scenes ────────────────────────────────────────────────────────────────
|
|
673
700
|
|
|
674
701
|
async checkScenesList() {
|
|
675
702
|
try {
|
|
676
703
|
if (this.logDebug) this.emit('debug', 'Scanning for scenes');
|
|
677
704
|
|
|
678
705
|
const resp = await this.client.get(ApiUrls.Home.Get.Scenes);
|
|
679
|
-
|
|
706
|
+
if (this.logDebug) this.emit('debug', `Scenes: ${JSON.stringify(resp.data, null, 2)}`);
|
|
680
707
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
return this.capitalizeKeysDeep(scenesList);
|
|
708
|
+
return this.capitalizeKeysDeep(resp.data);
|
|
684
709
|
} catch (error) {
|
|
685
710
|
throw new Error(`Check scenes list error: ${error.message}`);
|
|
686
711
|
}
|
|
687
712
|
}
|
|
688
713
|
|
|
714
|
+
// ── Devices ───────────────────────────────────────────────────────────────
|
|
715
|
+
|
|
689
716
|
async checkDevicesList() {
|
|
690
717
|
try {
|
|
691
718
|
const result = { State: false, Status: null, Buildings: {}, Devices: [], Scenes: [] };
|
|
@@ -693,11 +720,11 @@ class MelCloudHome extends EventEmitter {
|
|
|
693
720
|
|
|
694
721
|
const resp = await this.client.get(ApiUrls.Home.Get.Context);
|
|
695
722
|
const userContext = resp.data;
|
|
696
|
-
//if (this.logDebug) this.emit('debug', `User Context: ${JSON.stringify(userContext, null, 2)}`);
|
|
697
723
|
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
|
|
724
|
+
const buildingsList = [
|
|
725
|
+
...(userContext.buildings ?? []),
|
|
726
|
+
...(userContext.guestBuildings ?? []),
|
|
727
|
+
];
|
|
701
728
|
|
|
702
729
|
if (this.logDebug) this.emit('debug', `Buildings: ${JSON.stringify(buildingsList, null, 2)}`);
|
|
703
730
|
|
|
@@ -706,6 +733,7 @@ class MelCloudHome extends EventEmitter {
|
|
|
706
733
|
return result;
|
|
707
734
|
}
|
|
708
735
|
|
|
736
|
+
// Shallow capitalize — used for flat objects (Capabilities, FrostProtection, etc.)
|
|
709
737
|
const capitalizeKeys = obj => Object.fromEntries(
|
|
710
738
|
Object.entries(obj).map(([k, v]) => [k.charAt(0).toUpperCase() + k.slice(1), v])
|
|
711
739
|
);
|
|
@@ -729,7 +757,9 @@ class MelCloudHome extends EventEmitter {
|
|
|
729
757
|
if (device.FrostProtection) device.FrostProtection = capitalizeKeys(device.FrostProtection);
|
|
730
758
|
if (device.OverheatProtection) device.OverheatProtection = capitalizeKeys(device.OverheatProtection);
|
|
731
759
|
if (device.HolidayMode) device.HolidayMode = capitalizeKeys(device.HolidayMode);
|
|
732
|
-
if (Array.isArray(device.Schedule))
|
|
760
|
+
if (Array.isArray(device.Schedule)) {
|
|
761
|
+
device.Schedule = device.Schedule.map(s => this.capitalizeKeysDeep(s));
|
|
762
|
+
}
|
|
733
763
|
|
|
734
764
|
const { Settings, Capabilities, Id, GivenDisplayName, ...rest } = device;
|
|
735
765
|
|
|
@@ -754,13 +784,12 @@ class MelCloudHome extends EventEmitter {
|
|
|
754
784
|
return result;
|
|
755
785
|
}
|
|
756
786
|
|
|
757
|
-
// Sceny
|
|
758
787
|
let scenes = [];
|
|
759
788
|
try {
|
|
760
789
|
scenes = await this.checkScenesList();
|
|
761
790
|
if (this.logDebug) this.emit('debug', `Found ${scenes.length} scenes`);
|
|
762
791
|
} catch (error) {
|
|
763
|
-
if (this.logError) this.emit('error', `Get scenes error: ${error}`);
|
|
792
|
+
if (this.logError) this.emit('error', `Get scenes error: ${error.message}`);
|
|
764
793
|
}
|
|
765
794
|
|
|
766
795
|
result.State = true;
|