homebridge-melcloud-control 4.9.2-beta.8 → 4.10.0-beta.1
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 +260 -231
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.
|
|
4
|
+
"version": "4.10.0-beta.1",
|
|
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
|
-
this.emit('client', this.client);
|
|
428
448
|
|
|
429
449
|
if (this.pluginStart) {
|
|
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
|
}
|
|
@@ -439,130 +460,25 @@ class MelCloudHome extends EventEmitter {
|
|
|
439
460
|
return connectInfo;
|
|
440
461
|
}
|
|
441
462
|
|
|
442
|
-
// ── Scenes & Devices ──────────────────────────────────────────────────────
|
|
443
|
-
|
|
444
|
-
async checkScenesList() {
|
|
445
|
-
try {
|
|
446
|
-
if (this.logDebug) this.emit('debug', 'Scanning for scenes');
|
|
447
|
-
|
|
448
|
-
const resp = await this.client.get(ApiUrls.Home.Get.Scenes);
|
|
449
|
-
const scenesList = resp.data;
|
|
450
|
-
|
|
451
|
-
if (this.logDebug) this.emit('debug', `Scenes: ${JSON.stringify(scenesList, null, 2)}`);
|
|
452
|
-
|
|
453
|
-
return this.capitalizeKeysDeep(scenesList);
|
|
454
|
-
} catch (error) {
|
|
455
|
-
throw new Error(`Check scenes list error: ${error.message}`);
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
async checkDevicesList() {
|
|
460
|
-
try {
|
|
461
|
-
const result = { State: false, Status: null, Buildings: {}, Devices: [], Scenes: [] };
|
|
462
|
-
if (this.logDebug) this.emit('debug', 'Scanning for devices');
|
|
463
|
-
|
|
464
|
-
const resp = await this.client.get(ApiUrls.Home.Get.Context);
|
|
465
|
-
const userContext = resp.data;
|
|
466
|
-
//if (this.logDebug) this.emit('debug', `User Context: ${JSON.stringify(userContext, null, 2)}`);
|
|
467
|
-
|
|
468
|
-
const buildings = userContext.buildings ?? [];
|
|
469
|
-
const guestBuildings = userContext.guestBuildings ?? [];
|
|
470
|
-
const buildingsList = [...buildings, ...guestBuildings];
|
|
471
|
-
|
|
472
|
-
if (this.logDebug) this.emit('debug', `Buildings: ${JSON.stringify(buildingsList, null, 2)}`);
|
|
473
|
-
|
|
474
|
-
if (buildingsList.length === 0) {
|
|
475
|
-
result.Status = 'No buildings found';
|
|
476
|
-
return result;
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
const capitalizeKeys = obj => Object.fromEntries(
|
|
480
|
-
Object.entries(obj).map(([k, v]) => [k.charAt(0).toUpperCase() + k.slice(1), v])
|
|
481
|
-
);
|
|
482
|
-
|
|
483
|
-
const createDevice = (device, type) => {
|
|
484
|
-
const settingsObject = Object.fromEntries(
|
|
485
|
-
(device.Settings || []).map(({ name, value }) => [
|
|
486
|
-
name.charAt(0).toUpperCase() + name.slice(1),
|
|
487
|
-
this.functions.convertValue(value),
|
|
488
|
-
])
|
|
489
|
-
);
|
|
490
|
-
|
|
491
|
-
const deviceObject = {
|
|
492
|
-
...capitalizeKeys(device.Capabilities || {}),
|
|
493
|
-
...settingsObject,
|
|
494
|
-
DeviceType: type,
|
|
495
|
-
FirmwareAppVersion: device.ConnectedInterfaceIdentifier,
|
|
496
|
-
IsConnected: device.IsConnected,
|
|
497
|
-
};
|
|
498
|
-
|
|
499
|
-
if (device.FrostProtection) device.FrostProtection = capitalizeKeys(device.FrostProtection);
|
|
500
|
-
if (device.OverheatProtection) device.OverheatProtection = capitalizeKeys(device.OverheatProtection);
|
|
501
|
-
if (device.HolidayMode) device.HolidayMode = capitalizeKeys(device.HolidayMode);
|
|
502
|
-
if (Array.isArray(device.Schedule)) device.Schedule = device.Schedule.map(s => this.capitalizeKeysDeep(s));
|
|
503
|
-
|
|
504
|
-
const { Settings, Capabilities, Id, GivenDisplayName, ...rest } = device;
|
|
505
|
-
|
|
506
|
-
return {
|
|
507
|
-
...rest,
|
|
508
|
-
Type: type,
|
|
509
|
-
DeviceID: Id,
|
|
510
|
-
DeviceName: GivenDisplayName,
|
|
511
|
-
SerialNumber: Id,
|
|
512
|
-
Device: deviceObject,
|
|
513
|
-
};
|
|
514
|
-
};
|
|
515
|
-
|
|
516
|
-
const devices = buildingsList.flatMap(building => [
|
|
517
|
-
...(building.airToAirUnits || []).map(d => createDevice(capitalizeKeys(d), 0)),
|
|
518
|
-
...(building.airToWaterUnits || []).map(d => createDevice(capitalizeKeys(d), 1)),
|
|
519
|
-
...(building.airToVentilationUnits || []).map(d => createDevice(capitalizeKeys(d), 3)),
|
|
520
|
-
]);
|
|
521
|
-
|
|
522
|
-
if (devices.length === 0) {
|
|
523
|
-
result.Status = 'No devices found';
|
|
524
|
-
return result;
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
// Sceny
|
|
528
|
-
let scenes = [];
|
|
529
|
-
try {
|
|
530
|
-
scenes = await this.checkScenesList();
|
|
531
|
-
if (this.logDebug) this.emit('debug', `Found ${scenes.length} scenes`);
|
|
532
|
-
} catch (error) {
|
|
533
|
-
if (this.logError) this.emit('error', `Get scenes error: ${error}`);
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
result.State = true;
|
|
537
|
-
result.Status = `Found ${devices.length} devices${scenes.length > 0 ? ` and ${scenes.length} scenes` : ''}`;
|
|
538
|
-
result.Buildings = userContext;
|
|
539
|
-
result.Devices = devices;
|
|
540
|
-
result.Scenes = scenes;
|
|
541
|
-
|
|
542
|
-
for (const deviceData of result.Devices) {
|
|
543
|
-
deviceData.Scenes = result.Scenes;
|
|
544
|
-
this.emit(deviceData.DeviceID, 'request', deviceData);
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
return result;
|
|
548
|
-
} catch (error) {
|
|
549
|
-
throw new Error(`Check devices list error: ${error.message}`);
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
|
|
553
463
|
// ── Connect ───────────────────────────────────────────────────────────────
|
|
554
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
|
|
555
472
|
async connect() {
|
|
556
473
|
if (this.logDebug) this.emit('debug', 'Connecting to MELCloud Home');
|
|
557
474
|
|
|
558
475
|
try {
|
|
559
476
|
const connectInfo = { State: false, Status: '', Account: {}, UseFahrenheit: false };
|
|
560
|
-
|
|
561
477
|
const client = this.ensureAuthClient();
|
|
562
478
|
const { verifier: codeVerifier, challenge: codeChallenge } = this.generatePkce();
|
|
563
479
|
const state = crypto.randomBytes(16).toString('base64url');
|
|
564
480
|
|
|
565
|
-
// ── Step 1: PAR
|
|
481
|
+
// ── Step 1: PAR ───────────────────────────────────────────────────
|
|
566
482
|
if (this.logDebug) this.emit('debug', 'Step 1: PAR request');
|
|
567
483
|
|
|
568
484
|
const parResp = await this.pace(() =>
|
|
@@ -609,18 +525,16 @@ class MelCloudHome extends EventEmitter {
|
|
|
609
525
|
|
|
610
526
|
const finalUrl = authResp.request?.res?.responseUrl ?? authorizeUrl;
|
|
611
527
|
const parsed = new URL(finalUrl);
|
|
612
|
-
const body = typeof authResp.data === 'string'
|
|
613
|
-
? authResp.data
|
|
614
|
-
: JSON.stringify(authResp.data);
|
|
528
|
+
const body = typeof authResp.data === 'string' ? authResp.data : JSON.stringify(authResp.data);
|
|
615
529
|
|
|
616
530
|
if (parsed.hostname?.endsWith(ApiUrls.Home.CognitoDomainSuffix) && parsed.pathname.includes('/login')) {
|
|
617
|
-
// Happy path:
|
|
531
|
+
// Happy path: landed on the Cognito login page
|
|
618
532
|
csrfToken = this.extractCsrfToken(body);
|
|
619
533
|
if (!csrfToken) throw new Error('Failed to extract CSRF token from Cognito login page');
|
|
620
534
|
cognitoLoginUrl = finalUrl;
|
|
621
535
|
if (this.logDebug) this.emit('debug', 'Cognito login page OK');
|
|
622
536
|
} else {
|
|
623
|
-
// Fast path:
|
|
537
|
+
// Fast path: existing IdentityServer session — auth code available immediately
|
|
624
538
|
const codeMatch = /code=([^&"' ]+)/.exec(finalUrl) || /code=([^&"' ]+)/.exec(body);
|
|
625
539
|
if (codeMatch) {
|
|
626
540
|
authCode = codeMatch[1];
|
|
@@ -636,20 +550,21 @@ class MelCloudHome extends EventEmitter {
|
|
|
636
550
|
}
|
|
637
551
|
}
|
|
638
552
|
|
|
639
|
-
//
|
|
553
|
+
// Skip credential submission when we already have a code
|
|
640
554
|
if (authCode) {
|
|
641
555
|
if (this.logDebug) this.emit('debug', 'Re-login with existing session (skipping credentials)');
|
|
642
556
|
const exchangeRes = await this.exchangeCodeForTokens(client, authCode, codeVerifier);
|
|
643
557
|
return await this.buildConnectInfo(connectInfo, exchangeRes);
|
|
644
558
|
}
|
|
645
559
|
|
|
646
|
-
// ── 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.
|
|
647
564
|
if (this.logDebug) this.emit('debug', 'Step 3: Submit credentials to Cognito');
|
|
648
565
|
|
|
649
566
|
const cognitoHostname = new URL(cognitoLoginUrl).hostname;
|
|
650
567
|
|
|
651
|
-
// maxRedirects: 0 — Cognito używa response_mode=form_post.
|
|
652
|
-
// Przechwytujemy 302 zanim axios podąży za nim do IdentityServera (→ 500).
|
|
653
568
|
const credResp = await this.pace(() =>
|
|
654
569
|
client.post(
|
|
655
570
|
cognitoLoginUrl,
|
|
@@ -676,13 +591,14 @@ class MelCloudHome extends EventEmitter {
|
|
|
676
591
|
this.emit('debug', `Step 3 response location: ${credResp.headers?.location ?? '(none)'}`);
|
|
677
592
|
}
|
|
678
593
|
|
|
679
|
-
//
|
|
594
|
+
// HTTP 200 means Cognito returned the login page again → wrong password
|
|
680
595
|
if (credResp.status === 200) throw new Error('Authentication failed: Invalid username or password');
|
|
681
596
|
if (credResp.status >= 500) throw new Error(`Cognito server error: HTTP ${credResp.status}`);
|
|
682
597
|
|
|
683
|
-
// ── Step 4: POST
|
|
684
|
-
// Cognito
|
|
685
|
-
//
|
|
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.
|
|
686
602
|
if (this.logDebug) this.emit('debug', 'Step 4: Follow Cognito → IdentityServer redirect');
|
|
687
603
|
|
|
688
604
|
const cognitoRedirectLocation = credResp.headers?.location ?? '';
|
|
@@ -711,9 +627,9 @@ class MelCloudHome extends EventEmitter {
|
|
|
711
627
|
this.emit('debug', `Step 4 signin location: ${signinResp.headers?.location ?? '(none)'}`);
|
|
712
628
|
}
|
|
713
629
|
|
|
714
|
-
// ── Step 5:
|
|
715
|
-
// IdentityServer
|
|
716
|
-
//
|
|
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://
|
|
717
633
|
if (this.logDebug) this.emit('debug', 'Step 5: Following redirect chain to auth code');
|
|
718
634
|
|
|
719
635
|
let currentResp = signinResp;
|
|
@@ -726,13 +642,13 @@ class MelCloudHome extends EventEmitter {
|
|
|
726
642
|
|
|
727
643
|
if (this.logDebug) this.emit('debug', `Step 5 hop ${hop}: status=${hopStatus} location=${hopLocation || '(none)'}`);
|
|
728
644
|
|
|
729
|
-
// A:
|
|
645
|
+
// A: custom scheme redirect carrying the auth code
|
|
730
646
|
if (hopLocation.startsWith('melcloudhome://')) {
|
|
731
647
|
const m = /code=([^&"' ]+)/.exec(hopLocation);
|
|
732
648
|
if (m) { authCode = m[1]; break; }
|
|
733
649
|
}
|
|
734
650
|
|
|
735
|
-
// B:
|
|
651
|
+
// B: IdentityServer authorize callback — delegate to helper
|
|
736
652
|
const cbMatch = /\/connect\/authorize\/callback\?([^"' ]+)/.exec(hopLocation)
|
|
737
653
|
|| /\/connect\/authorize\/callback\?([^"' ]+)/.exec(hopBody);
|
|
738
654
|
if (cbMatch) {
|
|
@@ -741,15 +657,15 @@ class MelCloudHome extends EventEmitter {
|
|
|
741
657
|
break;
|
|
742
658
|
}
|
|
743
659
|
|
|
744
|
-
// C: code
|
|
660
|
+
// C: auth code directly in the Location header
|
|
745
661
|
const codeInLocation = /code=([^&"' ]+)/.exec(hopLocation);
|
|
746
662
|
if (codeInLocation) { authCode = codeInLocation[1]; break; }
|
|
747
663
|
|
|
748
|
-
// D: code
|
|
664
|
+
// D: auth code in the response body
|
|
749
665
|
const codeInBody = /code=([^&"' ]+)/.exec(hopBody);
|
|
750
666
|
if (codeInBody) { authCode = codeInBody[1]; break; }
|
|
751
667
|
|
|
752
|
-
//
|
|
668
|
+
// Standard redirect — follow the next hop
|
|
753
669
|
if ((hopStatus === 301 || hopStatus === 302 || hopStatus === 303) && hopLocation) {
|
|
754
670
|
const nextUrl = hopLocation.startsWith('http')
|
|
755
671
|
? hopLocation
|
|
@@ -771,7 +687,7 @@ class MelCloudHome extends EventEmitter {
|
|
|
771
687
|
|
|
772
688
|
if (this.logDebug) this.emit('debug', `Got auth code: ${authCode.slice(0, 20)}...`);
|
|
773
689
|
|
|
774
|
-
// ── Step 6:
|
|
690
|
+
// ── Step 6: Exchange auth code for tokens ─────────────────────────
|
|
775
691
|
const exchangeRes = await this.exchangeCodeForTokens(client, authCode, codeVerifier);
|
|
776
692
|
return await this.buildConnectInfo(connectInfo, exchangeRes);
|
|
777
693
|
|
|
@@ -779,6 +695,119 @@ class MelCloudHome extends EventEmitter {
|
|
|
779
695
|
throw new Error(`Connect error: ${error.message}`);
|
|
780
696
|
}
|
|
781
697
|
}
|
|
698
|
+
|
|
699
|
+
// ── Scenes ────────────────────────────────────────────────────────────────
|
|
700
|
+
|
|
701
|
+
async checkScenesList() {
|
|
702
|
+
try {
|
|
703
|
+
if (this.logDebug) this.emit('debug', 'Scanning for scenes');
|
|
704
|
+
|
|
705
|
+
const resp = await this.client.get(ApiUrls.Home.Get.Scenes);
|
|
706
|
+
if (this.logDebug) this.emit('debug', `Scenes: ${JSON.stringify(resp.data, null, 2)}`);
|
|
707
|
+
|
|
708
|
+
return this.capitalizeKeysDeep(resp.data);
|
|
709
|
+
} catch (error) {
|
|
710
|
+
throw new Error(`Check scenes list error: ${error.message}`);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ── Devices ───────────────────────────────────────────────────────────────
|
|
715
|
+
|
|
716
|
+
async checkDevicesList() {
|
|
717
|
+
try {
|
|
718
|
+
const result = { State: false, Status: null, Buildings: {}, Devices: [], Scenes: [] };
|
|
719
|
+
if (this.logDebug) this.emit('debug', 'Scanning for devices');
|
|
720
|
+
|
|
721
|
+
const resp = await this.client.get(ApiUrls.Home.Get.Context);
|
|
722
|
+
const userContext = resp.data;
|
|
723
|
+
|
|
724
|
+
const buildingsList = [
|
|
725
|
+
...(userContext.buildings ?? []),
|
|
726
|
+
...(userContext.guestBuildings ?? []),
|
|
727
|
+
];
|
|
728
|
+
|
|
729
|
+
if (this.logDebug) this.emit('debug', `Buildings: ${JSON.stringify(buildingsList, null, 2)}`);
|
|
730
|
+
|
|
731
|
+
if (buildingsList.length === 0) {
|
|
732
|
+
result.Status = 'No buildings found';
|
|
733
|
+
return result;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Shallow capitalize — used for flat objects (Capabilities, FrostProtection, etc.)
|
|
737
|
+
const capitalizeKeys = obj => Object.fromEntries(
|
|
738
|
+
Object.entries(obj).map(([k, v]) => [k.charAt(0).toUpperCase() + k.slice(1), v])
|
|
739
|
+
);
|
|
740
|
+
|
|
741
|
+
const createDevice = (device, type) => {
|
|
742
|
+
const settingsObject = Object.fromEntries(
|
|
743
|
+
(device.Settings || []).map(({ name, value }) => [
|
|
744
|
+
name.charAt(0).toUpperCase() + name.slice(1),
|
|
745
|
+
this.functions.convertValue(value),
|
|
746
|
+
])
|
|
747
|
+
);
|
|
748
|
+
|
|
749
|
+
const deviceObject = {
|
|
750
|
+
...capitalizeKeys(device.Capabilities || {}),
|
|
751
|
+
...settingsObject,
|
|
752
|
+
DeviceType: type,
|
|
753
|
+
FirmwareAppVersion: device.ConnectedInterfaceIdentifier,
|
|
754
|
+
IsConnected: device.IsConnected,
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
if (device.FrostProtection) device.FrostProtection = capitalizeKeys(device.FrostProtection);
|
|
758
|
+
if (device.OverheatProtection) device.OverheatProtection = capitalizeKeys(device.OverheatProtection);
|
|
759
|
+
if (device.HolidayMode) device.HolidayMode = capitalizeKeys(device.HolidayMode);
|
|
760
|
+
if (Array.isArray(device.Schedule)) {
|
|
761
|
+
device.Schedule = device.Schedule.map(s => this.capitalizeKeysDeep(s));
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const { Settings, Capabilities, Id, GivenDisplayName, ...rest } = device;
|
|
765
|
+
|
|
766
|
+
return {
|
|
767
|
+
...rest,
|
|
768
|
+
Type: type,
|
|
769
|
+
DeviceID: Id,
|
|
770
|
+
DeviceName: GivenDisplayName,
|
|
771
|
+
SerialNumber: Id,
|
|
772
|
+
Device: deviceObject,
|
|
773
|
+
};
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
const devices = buildingsList.flatMap(building => [
|
|
777
|
+
...(building.airToAirUnits || []).map(d => createDevice(capitalizeKeys(d), 0)),
|
|
778
|
+
...(building.airToWaterUnits || []).map(d => createDevice(capitalizeKeys(d), 1)),
|
|
779
|
+
...(building.airToVentilationUnits || []).map(d => createDevice(capitalizeKeys(d), 3)),
|
|
780
|
+
]);
|
|
781
|
+
|
|
782
|
+
if (devices.length === 0) {
|
|
783
|
+
result.Status = 'No devices found';
|
|
784
|
+
return result;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
let scenes = [];
|
|
788
|
+
try {
|
|
789
|
+
scenes = await this.checkScenesList();
|
|
790
|
+
if (this.logDebug) this.emit('debug', `Found ${scenes.length} scenes`);
|
|
791
|
+
} catch (error) {
|
|
792
|
+
if (this.logError) this.emit('error', `Get scenes error: ${error.message}`);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
result.State = true;
|
|
796
|
+
result.Status = `Found ${devices.length} devices${scenes.length > 0 ? ` and ${scenes.length} scenes` : ''}`;
|
|
797
|
+
result.Buildings = userContext;
|
|
798
|
+
result.Devices = devices;
|
|
799
|
+
result.Scenes = scenes;
|
|
800
|
+
|
|
801
|
+
for (const deviceData of result.Devices) {
|
|
802
|
+
deviceData.Scenes = result.Scenes;
|
|
803
|
+
this.emit(deviceData.DeviceID, 'request', deviceData);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
return result;
|
|
807
|
+
} catch (error) {
|
|
808
|
+
throw new Error(`Check devices list error: ${error.message}`);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
782
811
|
}
|
|
783
812
|
|
|
784
813
|
export default MelCloudHome;
|