@impact0815/node-red-contrib-home-connect 0.0.0-stage → 0.2.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/BUILD-INFO.json +23 -0
- package/CHANGELOG.md +53 -0
- package/LICENSE +21 -0
- package/NOTICE +19 -0
- package/README.de.md +344 -0
- package/README.md +343 -2
- package/SECURITY.md +47 -0
- package/docs/API-COVERAGE.md +59 -0
- package/docs/ARCHITECTURE.md +95 -0
- package/docs/DASHBOARD.de.md +121 -0
- package/docs/DASHBOARD.md +121 -0
- package/docs/FIRST-PUSH.md +42 -0
- package/docs/GITHUB-UPDATE.md +76 -0
- package/docs/LOCAL-LAN.de.md +150 -0
- package/docs/LOCAL-LAN.md +150 -0
- package/docs/MESSAGE-API.md +111 -0
- package/docs/RELEASE.md +91 -0
- package/docs/TROUBLESHOOTING.de.md +230 -0
- package/docs/TROUBLESHOOTING.md +230 -0
- package/docs/images/README.md +10 -0
- package/docs/images/dashboard-details-en.png +0 -0
- package/docs/images/dashboard-overview-en.png +0 -0
- package/docs/official-api-contract.json +418 -0
- package/examples/01-first-steps-device-list.json +148 -0
- package/examples/02-events.json +165 -0
- package/examples/03-safe-read-write.json +155 -0
- package/examples/04-local-read-only.json +214 -0
- package/examples/README.de.md +62 -0
- package/examples/README.md +62 -0
- package/examples/dashboard-dishwasher-washer-oven.json +628 -0
- package/examples/dashboard-screenshot-simulated.json +405 -0
- package/examples/demo-home-connect-flowfuse-1.34.1-scriptless.json +390 -0
- package/lib/client.js +600 -0
- package/lib/constants.js +66 -0
- package/lib/dashboard-i18n.js +255 -0
- package/lib/device-label.js +39 -0
- package/lib/errors.js +51 -0
- package/lib/health.js +525 -0
- package/lib/local-client.js +1014 -0
- package/lib/local-crypto.js +122 -0
- package/lib/local-diagnostics.js +115 -0
- package/lib/local-profile.js +706 -0
- package/lib/local-trace.js +103 -0
- package/lib/oauth.js +23 -0
- package/lib/rate-limiter.js +186 -0
- package/lib/simulator.js +290 -0
- package/lib/sse.js +58 -0
- package/lib/validation.js +194 -0
- package/lib/websocket-transport.js +374 -0
- package/nodes/home-connect.html +795 -0
- package/nodes/home-connect.js +699 -0
- package/nodes/locales/de/home-connect.html +19 -0
- package/nodes/locales/de/home-connect.json +10 -0
- package/nodes/locales/en-US/home-connect.json +10 -0
- package/package.json +76 -4
package/lib/client.js
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { apiError, HomeConnectError } = require('./errors');
|
|
4
|
+
const { parseSSE } = require('./sse');
|
|
5
|
+
const { validateWrite } = require('./validation');
|
|
6
|
+
const { API_MEDIA_TYPE, DEFAULT_SCOPE, ENVIRONMENTS, READ_OPERATIONS } = require('./constants');
|
|
7
|
+
const { SlidingWindowRateLimiter } = require('./rate-limiter');
|
|
8
|
+
|
|
9
|
+
const sleep = ms => new Promise(resolve => {
|
|
10
|
+
const timer = setTimeout(resolve, ms);
|
|
11
|
+
timer.unref?.();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
function errorJSON(error) {
|
|
15
|
+
return error?.toJSON ? error.toJSON() : { name: error?.name || 'Error', message: error?.message || String(error) };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function settle(resource, promise, fallback) {
|
|
19
|
+
try {
|
|
20
|
+
return { ok: true, resource, value: await promise };
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return { ok: false, resource, value: fallback, error: { resource, ...errorJSON(error) } };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function retryDelay(value, fallback, now = Date.now()) {
|
|
27
|
+
if (value == null || value === '') return fallback;
|
|
28
|
+
const seconds = Number(value);
|
|
29
|
+
if (Number.isFinite(seconds)) return Math.max(0, Math.min(seconds * 1000, 60000));
|
|
30
|
+
const date = Date.parse(value);
|
|
31
|
+
if (Number.isFinite(date)) return Math.max(0, Math.min(date - now, 60000));
|
|
32
|
+
return fallback;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function timedSignal(externalSignal, timeoutMs) {
|
|
36
|
+
const controller = new AbortController();
|
|
37
|
+
let timedOut = false;
|
|
38
|
+
const abort = () => controller.abort(externalSignal?.reason);
|
|
39
|
+
if (externalSignal?.aborted) abort();
|
|
40
|
+
else externalSignal?.addEventListener?.('abort', abort, { once: true });
|
|
41
|
+
const timer = setTimeout(() => {
|
|
42
|
+
timedOut = true;
|
|
43
|
+
controller.abort();
|
|
44
|
+
}, timeoutMs);
|
|
45
|
+
timer.unref?.();
|
|
46
|
+
return {
|
|
47
|
+
signal: controller.signal,
|
|
48
|
+
timedOut: () => timedOut,
|
|
49
|
+
cleanup: () => {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
externalSignal?.removeEventListener?.('abort', abort);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class HomeConnectClient {
|
|
57
|
+
constructor(options = {}) {
|
|
58
|
+
this.environment = options.environment || 'production';
|
|
59
|
+
this.baseUrl = options.baseUrl || ENVIRONMENTS[this.environment];
|
|
60
|
+
if (!this.baseUrl) throw new HomeConnectError(`Unsupported environment: ${this.environment}`, { code: 'INVALID_ENVIRONMENT' });
|
|
61
|
+
if (typeof (options.fetch || globalThis.fetch) !== 'function') {
|
|
62
|
+
throw new HomeConnectError('A Fetch API implementation is required.', { code: 'FETCH_UNAVAILABLE' });
|
|
63
|
+
}
|
|
64
|
+
this.clientId = options.clientId;
|
|
65
|
+
this.clientSecret = options.clientSecret;
|
|
66
|
+
this.redirectUri = options.redirectUri;
|
|
67
|
+
this.scope = options.scope || DEFAULT_SCOPE;
|
|
68
|
+
this.language = options.language || 'en-GB';
|
|
69
|
+
this.fetch = options.fetch || globalThis.fetch;
|
|
70
|
+
this.onToken = options.onToken || (() => {});
|
|
71
|
+
this.accessToken = options.accessToken;
|
|
72
|
+
this.refreshToken = options.refreshToken;
|
|
73
|
+
this.expiresAt = options.expiresAt || 0;
|
|
74
|
+
this.refreshPromise = null;
|
|
75
|
+
this.capabilityCache = new Map();
|
|
76
|
+
this.requestTimeout = Math.max(1000, Number(options.requestTimeout || 15000));
|
|
77
|
+
this.rateLimiter = options.rateLimiter || new SlidingWindowRateLimiter({
|
|
78
|
+
maxRequests: options.maxRequestsPerDay,
|
|
79
|
+
minimumIntervalMs: options.minimumRequestInterval,
|
|
80
|
+
store: options.rateLimitStore
|
|
81
|
+
});
|
|
82
|
+
this.supportsCommands = options.supportsCommands ?? this.environment !== 'official-simulator';
|
|
83
|
+
this.eventClosers = new Set();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get requiresOAuth() { return true; }
|
|
87
|
+
|
|
88
|
+
setTokens(tokens) {
|
|
89
|
+
if (!tokens?.access_token) {
|
|
90
|
+
throw new HomeConnectError('The token response did not contain an access token.', { code: 'TOKEN_RESPONSE_INVALID' });
|
|
91
|
+
}
|
|
92
|
+
this.accessToken = tokens.access_token;
|
|
93
|
+
this.refreshToken = tokens.refresh_token || this.refreshToken;
|
|
94
|
+
this.expiresAt = Date.now() + Math.max(30, Number(tokens.expires_in || 3600) - 30) * 1000;
|
|
95
|
+
this.onToken({ accessToken: this.accessToken, refreshToken: this.refreshToken, expiresAt: this.expiresAt });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async _fetchWithTimeout(url, options = {}) {
|
|
99
|
+
const timeout = timedSignal(options.signal, options.timeout || this.requestTimeout);
|
|
100
|
+
try {
|
|
101
|
+
return await this.fetch(url, { ...options, signal: timeout.signal });
|
|
102
|
+
} catch (cause) {
|
|
103
|
+
if (timeout.timedOut()) {
|
|
104
|
+
throw new HomeConnectError('Home Connect request timed out.', { code: 'REQUEST_TIMEOUT', status: 408, cause });
|
|
105
|
+
}
|
|
106
|
+
if (options.signal?.aborted || cause?.name === 'AbortError') {
|
|
107
|
+
throw new HomeConnectError('Home Connect request was aborted.', { code: 'REQUEST_ABORTED', cause });
|
|
108
|
+
}
|
|
109
|
+
throw new HomeConnectError('Network error while calling Home Connect.', { code: 'NETWORK_ERROR', cause });
|
|
110
|
+
} finally {
|
|
111
|
+
timeout.cleanup();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async _tokenRequest(parameters, options = {}) {
|
|
116
|
+
if (!this.clientId) throw new HomeConnectError('Client ID is required.', { code: 'CLIENT_ID_REQUIRED' });
|
|
117
|
+
if (!this.clientSecret) throw new HomeConnectError('Client secret is required for the configured confidential-client flow.', { code: 'CLIENT_SECRET_REQUIRED' });
|
|
118
|
+
const clean = Object.fromEntries(Object.entries({
|
|
119
|
+
...parameters,
|
|
120
|
+
client_id: this.clientId,
|
|
121
|
+
client_secret: this.clientSecret
|
|
122
|
+
}).filter(([, value]) => value !== undefined && value !== ''));
|
|
123
|
+
const response = await this._fetchWithTimeout(new URL('/security/oauth/token', this.baseUrl), {
|
|
124
|
+
method: 'POST',
|
|
125
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
126
|
+
body: new URLSearchParams(clean)
|
|
127
|
+
});
|
|
128
|
+
const text = await response.text();
|
|
129
|
+
const payload = text ? (() => { try { return JSON.parse(text); } catch { return { raw: text }; } })() : {};
|
|
130
|
+
if (!response.ok) throw apiError(response, payload);
|
|
131
|
+
if (options.requireRefreshToken && !payload.refresh_token) {
|
|
132
|
+
throw new HomeConnectError('The authorization-code token response did not contain a refresh token.', {
|
|
133
|
+
code: 'REFRESH_TOKEN_MISSING',
|
|
134
|
+
status: 502
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
this.setTokens(payload);
|
|
138
|
+
return payload;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async exchangeCode(code) {
|
|
142
|
+
if (!code) throw new HomeConnectError('Authorization code is required.', { code: 'AUTHORIZATION_CODE_REQUIRED' });
|
|
143
|
+
if (!this.redirectUri) throw new HomeConnectError('Redirect URI is required.', { code: 'REDIRECT_URI_REQUIRED' });
|
|
144
|
+
return this._tokenRequest({
|
|
145
|
+
grant_type: 'authorization_code',
|
|
146
|
+
code,
|
|
147
|
+
redirect_uri: this.redirectUri
|
|
148
|
+
}, { requireRefreshToken: true });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async refreshAccessToken() {
|
|
152
|
+
if (!this.refreshToken) throw new HomeConnectError('No refresh token is stored. Complete OAuth authorization first.', { code: 'NOT_AUTHORIZED' });
|
|
153
|
+
if (!this.refreshPromise) {
|
|
154
|
+
this.refreshPromise = this._tokenRequest({
|
|
155
|
+
grant_type: 'refresh_token',
|
|
156
|
+
refresh_token: this.refreshToken
|
|
157
|
+
}).finally(() => { this.refreshPromise = null; });
|
|
158
|
+
}
|
|
159
|
+
return this.refreshPromise;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async ensureAccessToken() {
|
|
163
|
+
if (this.accessToken && Date.now() < this.expiresAt) return this.accessToken;
|
|
164
|
+
await this.refreshAccessToken();
|
|
165
|
+
return this.accessToken;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async _rateLimit() {
|
|
169
|
+
return this.rateLimiter.acquire();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async getRateLimitStatus() {
|
|
173
|
+
return this.rateLimiter.status();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async request(method, path, options = {}) {
|
|
177
|
+
const verb = String(method).toUpperCase();
|
|
178
|
+
const isRead = verb === 'GET';
|
|
179
|
+
const maxAttempts = isRead ? 4 : 1;
|
|
180
|
+
let unauthorizedRetry = false;
|
|
181
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
182
|
+
await this._rateLimit();
|
|
183
|
+
const token = await this.ensureAccessToken();
|
|
184
|
+
const headers = {
|
|
185
|
+
...options.headers,
|
|
186
|
+
authorization: 'Bearer ' + token,
|
|
187
|
+
accept: options.accept || API_MEDIA_TYPE,
|
|
188
|
+
'accept-language': this.language
|
|
189
|
+
};
|
|
190
|
+
let body;
|
|
191
|
+
if (options.body !== undefined) {
|
|
192
|
+
headers['content-type'] = API_MEDIA_TYPE;
|
|
193
|
+
body = JSON.stringify(options.body);
|
|
194
|
+
}
|
|
195
|
+
let response;
|
|
196
|
+
try {
|
|
197
|
+
response = await this._fetchWithTimeout(new URL(path, this.baseUrl), {
|
|
198
|
+
method: verb, headers, body, signal: options.signal
|
|
199
|
+
});
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (isRead && ['NETWORK_ERROR', 'REQUEST_TIMEOUT'].includes(error.code) && attempt + 1 < maxAttempts) {
|
|
202
|
+
await sleep(250 * 2 ** attempt);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
if (response.ok && options.binary) {
|
|
208
|
+
return {
|
|
209
|
+
contentType: response.headers.get('content-type') || options.accept || 'application/octet-stream',
|
|
210
|
+
data: Buffer.from(await response.arrayBuffer())
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const text = response.status === 204 ? '' : await response.text();
|
|
214
|
+
const payload = text ? (() => { try { return JSON.parse(text); } catch { return { raw: text }; } })() : {};
|
|
215
|
+
if (response.ok) return payload.data === undefined ? payload : payload.data;
|
|
216
|
+
if (response.status === 401 && !unauthorizedRetry) {
|
|
217
|
+
unauthorizedRetry = true;
|
|
218
|
+
this.accessToken = null;
|
|
219
|
+
await this.refreshAccessToken();
|
|
220
|
+
attempt--;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const error = apiError(response, payload);
|
|
224
|
+
if (response.status === 429) {
|
|
225
|
+
await this.rateLimiter.recordServerLimit(error.retryAfter || error.message);
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
if (isRead && response.status >= 500 && attempt + 1 < maxAttempts) {
|
|
229
|
+
await sleep(retryDelay(response.headers.get('retry-after'), 300 * 2 ** attempt));
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
throw new HomeConnectError('Request failed after all retries.', { code: 'RETRY_EXHAUSTED' });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
_path(haId, suffix = '') {
|
|
238
|
+
if (!haId) throw new HomeConnectError('haId is required.', { code: 'HAID_REQUIRED' });
|
|
239
|
+
return `/api/homeappliances/${encodeURIComponent(haId)}${suffix}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
_key(key, label = 'key') {
|
|
243
|
+
if (typeof key !== 'string' || !key) throw new HomeConnectError(`${label} is required.`, { code: 'KEY_REQUIRED' });
|
|
244
|
+
return encodeURIComponent(key);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
_identifier(value, label) {
|
|
248
|
+
if (value === undefined || value === null || value === '') {
|
|
249
|
+
throw new HomeConnectError(`${label} is required.`, { code: 'KEY_REQUIRED' });
|
|
250
|
+
}
|
|
251
|
+
return encodeURIComponent(String(value));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async listHomeAppliances() {
|
|
255
|
+
const data = await this.request('GET', '/api/homeappliances');
|
|
256
|
+
return data.homeappliances || [];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async getHomeAppliance(haId) {
|
|
260
|
+
const data = await this.request('GET', this._path(haId));
|
|
261
|
+
return data.homeappliance || data;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async getStatus(haId) {
|
|
265
|
+
const data = await this.request('GET', this._path(haId, '/status'));
|
|
266
|
+
return { items: data.status || data.items || [] };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async getStatusValue(haId, key) {
|
|
270
|
+
return this.request('GET', this._path(haId, `/status/${this._key(key, 'status key')}`));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async getSettings(haId) {
|
|
274
|
+
const data = await this.request('GET', this._path(haId, '/settings'));
|
|
275
|
+
return { items: data.settings || data.items || [] };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async getSetting(haId, key) {
|
|
279
|
+
return this.request('GET', this._path(haId, `/settings/${this._key(key, 'setting key')}`));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async getAllPrograms(haId) {
|
|
283
|
+
return this.request('GET', this._path(haId, '/programs'));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async getAvailablePrograms(haId) {
|
|
287
|
+
const data = await this.request('GET', this._path(haId, '/programs/available'));
|
|
288
|
+
return { programs: data.programs || [] };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async getAvailableProgram(haId, key) {
|
|
292
|
+
return this.request('GET', this._path(haId, `/programs/available/${this._key(key, 'program key')}`));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async getActiveProgram(haId) { return this.request('GET', this._path(haId, '/programs/active')); }
|
|
296
|
+
async getSelectedProgram(haId) { return this.request('GET', this._path(haId, '/programs/selected')); }
|
|
297
|
+
|
|
298
|
+
async getProgramOptions(haId, kind) {
|
|
299
|
+
return this.request('GET', this._path(haId, `/programs/${kind}/options`));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async getProgramOption(haId, kind, key) {
|
|
303
|
+
return this.request('GET', this._path(haId, `/programs/${kind}/options/${this._key(key, 'option key')}`));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async getCommands(haId) {
|
|
307
|
+
if (!this.supportsCommands) return { items: [], unsupported: true };
|
|
308
|
+
const data = await this.request('GET', this._path(haId, '/commands'));
|
|
309
|
+
return { items: data.commands || data.items || [] };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async getImages(haId) {
|
|
313
|
+
const data = await this.request('GET', this._path(haId, '/images'));
|
|
314
|
+
return { images: data.images || [] };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async getImage(haId, imageKey) {
|
|
318
|
+
return this.request('GET', this._path(haId, `/images/${this._key(imageKey, 'image key')}`), {
|
|
319
|
+
accept: 'image/jpeg', binary: true
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async getMaps(haId) {
|
|
324
|
+
return this.request('GET', this._path(haId, '/map/maps'), { accept: 'application/json' });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async getMap(haId, mapId) {
|
|
328
|
+
return this.request('GET', this._path(haId, `/map/maps/${this._identifier(mapId, 'map ID')}`), { accept: 'application/json' });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async getMapResource(haId, mapId, resource, itemId) {
|
|
332
|
+
const map = this._identifier(mapId, 'map ID');
|
|
333
|
+
const suffix = itemId === undefined
|
|
334
|
+
? `/map/maps/${map}/${resource}`
|
|
335
|
+
: `/map/maps/${map}/${resource}/${this._identifier(itemId, 'resource ID')}`;
|
|
336
|
+
return this.request('GET', this._path(haId, suffix), { accept: 'application/json' });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async getMapGlobalResource(haId, resource, itemId) {
|
|
340
|
+
const suffix = itemId === undefined
|
|
341
|
+
? `/map/${resource}`
|
|
342
|
+
: `/map/${resource}/${this._identifier(itemId, 'resource ID')}`;
|
|
343
|
+
return this.request('GET', this._path(haId, suffix), { accept: 'application/json' });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async discoverAppliance(haId, force = false) {
|
|
347
|
+
const cached = this.capabilityCache.get(haId);
|
|
348
|
+
if (!force && cached && Date.now() - cached.cachedAt < 300000) return structuredClone(cached.value);
|
|
349
|
+
const appliance = await this.getHomeAppliance(haId);
|
|
350
|
+
const resources = await Promise.all([
|
|
351
|
+
settle('status', this.getStatus(haId), { items: [] }),
|
|
352
|
+
settle('settings', this.getSettings(haId), { items: [] }),
|
|
353
|
+
settle('available-programs', this.getAvailablePrograms(haId), { programs: [] }),
|
|
354
|
+
settle('active-program', this.getActiveProgram(haId), null),
|
|
355
|
+
settle('selected-program', this.getSelectedProgram(haId), null),
|
|
356
|
+
settle('commands', this.getCommands(haId), { items: [], unsupported: !this.supportsCommands })
|
|
357
|
+
]);
|
|
358
|
+
const [status, settings, availableList, active, selected, commands] = resources;
|
|
359
|
+
const available = [];
|
|
360
|
+
const detailErrors = [];
|
|
361
|
+
for (let index = 0; index < availableList.value.programs.length; index += 3) {
|
|
362
|
+
const batch = availableList.value.programs.slice(index, index + 3);
|
|
363
|
+
const details = await Promise.all(batch.map(program => settle(
|
|
364
|
+
`program:${program.key}`,
|
|
365
|
+
this.getAvailableProgram(haId, program.key),
|
|
366
|
+
program
|
|
367
|
+
)));
|
|
368
|
+
available.push(...details.map(detail => detail.value));
|
|
369
|
+
detailErrors.push(...details.filter(detail => !detail.ok).map(detail => detail.error));
|
|
370
|
+
}
|
|
371
|
+
const value = {
|
|
372
|
+
appliance,
|
|
373
|
+
status: status.value,
|
|
374
|
+
settings: settings.value,
|
|
375
|
+
programs: { available, active: active.value, selected: selected.value },
|
|
376
|
+
commands: commands.value,
|
|
377
|
+
discoveryErrors: [...resources.filter(entry => !entry.ok).map(entry => entry.error), ...detailErrors],
|
|
378
|
+
discoveredAt: new Date().toISOString(),
|
|
379
|
+
simulated: false
|
|
380
|
+
};
|
|
381
|
+
this.capabilityCache.set(haId, { cachedAt: Date.now(), value });
|
|
382
|
+
return structuredClone(value);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async discoverAll(force = false) {
|
|
386
|
+
const appliances = await this.listHomeAppliances();
|
|
387
|
+
const result = [];
|
|
388
|
+
for (const appliance of appliances) {
|
|
389
|
+
try {
|
|
390
|
+
result.push(await this.discoverAppliance(appliance.haId, force));
|
|
391
|
+
} catch (error) {
|
|
392
|
+
result.push({ appliance, error: errorJSON(error) });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return result;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async getSnapshot(haId) {
|
|
399
|
+
const capability = await this.discoverAppliance(haId, false);
|
|
400
|
+
const resources = await Promise.all([
|
|
401
|
+
settle('status', this.getStatus(haId), capability.status),
|
|
402
|
+
settle('settings', this.getSettings(haId), capability.settings),
|
|
403
|
+
settle('active-program', this.getActiveProgram(haId), capability.programs.active),
|
|
404
|
+
settle('selected-program', this.getSelectedProgram(haId), capability.programs.selected)
|
|
405
|
+
]);
|
|
406
|
+
capability.status = resources[0].value;
|
|
407
|
+
capability.settings = resources[1].value;
|
|
408
|
+
capability.programs.active = resources[2].value;
|
|
409
|
+
capability.programs.selected = resources[3].value;
|
|
410
|
+
capability.snapshotAt = new Date().toISOString();
|
|
411
|
+
capability.snapshotErrors = resources.filter(entry => !entry.ok).map(entry => entry.error);
|
|
412
|
+
return capability;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async read(haId, operation = 'snapshot', key, subKey) {
|
|
416
|
+
if (!READ_OPERATIONS.includes(operation)) {
|
|
417
|
+
throw new HomeConnectError(`Unsupported read operation: ${operation}`, { code: 'READ_ACTION_NOT_ALLOWED' });
|
|
418
|
+
}
|
|
419
|
+
if (operation === 'appliances') return this.listHomeAppliances();
|
|
420
|
+
if (!haId) throw new HomeConnectError('haId is required.', { code: 'HAID_REQUIRED' });
|
|
421
|
+
const operations = {
|
|
422
|
+
snapshot: () => this.getSnapshot(haId),
|
|
423
|
+
capabilities: () => this.discoverAppliance(haId, true),
|
|
424
|
+
appliance: () => this.getHomeAppliance(haId),
|
|
425
|
+
status: () => this.getStatus(haId),
|
|
426
|
+
'status-item': () => this.getStatusValue(haId, key),
|
|
427
|
+
settings: () => this.getSettings(haId),
|
|
428
|
+
setting: () => this.getSetting(haId, key),
|
|
429
|
+
'all-programs': () => this.getAllPrograms(haId),
|
|
430
|
+
programs: () => this.getAvailablePrograms(haId),
|
|
431
|
+
program: () => this.getAvailableProgram(haId, key),
|
|
432
|
+
'active-program': () => this.getActiveProgram(haId),
|
|
433
|
+
'active-options': () => this.getProgramOptions(haId, 'active'),
|
|
434
|
+
'active-option': () => this.getProgramOption(haId, 'active', key),
|
|
435
|
+
'selected-program': () => this.getSelectedProgram(haId),
|
|
436
|
+
'selected-options': () => this.getProgramOptions(haId, 'selected'),
|
|
437
|
+
'selected-option': () => this.getProgramOption(haId, 'selected', key),
|
|
438
|
+
commands: () => this.getCommands(haId),
|
|
439
|
+
images: () => this.getImages(haId),
|
|
440
|
+
image: () => this.getImage(haId, key),
|
|
441
|
+
maps: () => this.getMaps(haId),
|
|
442
|
+
map: () => this.getMap(haId, key),
|
|
443
|
+
'map-occupancy-grid': () => this.getMapResource(haId, key, 'occupancyGrid'),
|
|
444
|
+
'map-layout': () => this.getMapResource(haId, key, 'layout'),
|
|
445
|
+
'map-cleaning-status': () => this.getMapResource(haId, key, 'cleaningStatus'),
|
|
446
|
+
'map-detected-objects': () => this.getMapResource(haId, key, 'cleaningStatus/objects'),
|
|
447
|
+
'map-detected-object': () => this.getMapResource(haId, key, 'cleaningStatus/objects', subKey),
|
|
448
|
+
'map-rooms': () => this.getMapResource(haId, key, 'rooms'),
|
|
449
|
+
'map-room': () => this.getMapResource(haId, key, 'rooms', subKey),
|
|
450
|
+
'map-zones': () => this.getMapResource(haId, key, 'zones'),
|
|
451
|
+
'map-zone': () => this.getMapResource(haId, key, 'zones', subKey),
|
|
452
|
+
'map-robot-position': () => this.getMapGlobalResource(haId, 'robot/position'),
|
|
453
|
+
'map-config-restrictions': () => this.getMapGlobalResource(haId, 'config/restrictions'),
|
|
454
|
+
'map-cleaning-summaries': () => this.getMapGlobalResource(haId, 'cleaningSummary'),
|
|
455
|
+
'map-cleaning-summary': () => this.getMapGlobalResource(haId, 'cleaningSummary', key)
|
|
456
|
+
};
|
|
457
|
+
return operations[operation]();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async write(request, capability) {
|
|
461
|
+
const cap = capability || await this.discoverAppliance(request.haId, true);
|
|
462
|
+
validateWrite(cap, request);
|
|
463
|
+
const id = encodeURIComponent(request.haId);
|
|
464
|
+
let result;
|
|
465
|
+
if (request.action === 'set-setting') {
|
|
466
|
+
result = await this.request('PUT', `/api/homeappliances/${id}/settings/${this._key(request.key, 'setting key')}`, {
|
|
467
|
+
body: { data: { key: request.key, value: request.value } }
|
|
468
|
+
});
|
|
469
|
+
} else if (request.action === 'select-program') {
|
|
470
|
+
result = await this.request('PUT', `/api/homeappliances/${id}/programs/selected`, {
|
|
471
|
+
body: { data: { key: request.program, options: request.options || [] } }
|
|
472
|
+
});
|
|
473
|
+
} else if (request.action === 'start-program') {
|
|
474
|
+
result = await this.request('PUT', `/api/homeappliances/${id}/programs/active`, {
|
|
475
|
+
body: { data: { key: request.program, options: request.options || [] } }
|
|
476
|
+
});
|
|
477
|
+
} else if (request.action === 'stop-program') {
|
|
478
|
+
result = await this.request('DELETE', `/api/homeappliances/${id}/programs/active`);
|
|
479
|
+
} else if (request.action === 'command') {
|
|
480
|
+
result = await this.request('PUT', `/api/homeappliances/${id}/commands/${this._key(request.key, 'command key')}`, {
|
|
481
|
+
body: { data: { key: request.key, value: request.value } }
|
|
482
|
+
});
|
|
483
|
+
} else {
|
|
484
|
+
const kind = request.action === 'set-active-option' ? 'active' : 'selected';
|
|
485
|
+
result = await this.request('PUT', `/api/homeappliances/${id}/programs/${kind}/options/${this._key(request.key, 'option key')}`, {
|
|
486
|
+
body: { data: { key: request.key, value: request.value } }
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
this.capabilityCache.delete(request.haId);
|
|
490
|
+
return result;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
openEvents(haId, onEvent, onError = () => {}) {
|
|
494
|
+
let closed = false;
|
|
495
|
+
let controller;
|
|
496
|
+
let lastEventId;
|
|
497
|
+
const path = haId && haId !== '*' ? `/api/homeappliances/${encodeURIComponent(haId)}/events` : '/api/homeappliances/events';
|
|
498
|
+
const close = () => {
|
|
499
|
+
closed = true;
|
|
500
|
+
controller?.abort();
|
|
501
|
+
this.eventClosers.delete(close);
|
|
502
|
+
};
|
|
503
|
+
this.eventClosers.add(close);
|
|
504
|
+
const run = async () => {
|
|
505
|
+
let backoff = 1000;
|
|
506
|
+
while (!closed) {
|
|
507
|
+
controller = new AbortController();
|
|
508
|
+
let announcedConnected = false;
|
|
509
|
+
try {
|
|
510
|
+
await this._rateLimit();
|
|
511
|
+
const token = await this.ensureAccessToken();
|
|
512
|
+
const headers = {
|
|
513
|
+
authorization: 'Bearer ' + token,
|
|
514
|
+
accept: 'text/event-stream',
|
|
515
|
+
'accept-language': this.language
|
|
516
|
+
};
|
|
517
|
+
if (lastEventId) headers['last-event-id'] = lastEventId;
|
|
518
|
+
const response = await this.fetch(new URL(path, this.baseUrl), { headers, signal: controller.signal });
|
|
519
|
+
if (response.status === 401) {
|
|
520
|
+
this.accessToken = null;
|
|
521
|
+
await this.refreshAccessToken();
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (!response.ok) {
|
|
525
|
+
const payload = await response.json().catch(() => ({}));
|
|
526
|
+
const error = apiError(response, payload);
|
|
527
|
+
if (response.status === 429) await this.rateLimiter.recordServerLimit(error.retryAfter || error.message);
|
|
528
|
+
throw error;
|
|
529
|
+
}
|
|
530
|
+
backoff = 1000;
|
|
531
|
+
announcedConnected = true;
|
|
532
|
+
onEvent({
|
|
533
|
+
haId: haId || '*',
|
|
534
|
+
event: 'CONNECTED',
|
|
535
|
+
data: { items: [] },
|
|
536
|
+
timestamp: new Date().toISOString(),
|
|
537
|
+
connection: { synthetic: true },
|
|
538
|
+
simulated: false
|
|
539
|
+
});
|
|
540
|
+
for await (const frame of parseSSE(response.body)) {
|
|
541
|
+
if (closed) break;
|
|
542
|
+
let data = frame.data;
|
|
543
|
+
if (data) { try { data = JSON.parse(data); } catch { /* keep text */ } }
|
|
544
|
+
if (frame.id) lastEventId = frame.id;
|
|
545
|
+
const eventHaId = data && typeof data === 'object' && typeof data.haId === 'string'
|
|
546
|
+
? data.haId
|
|
547
|
+
: (haId || '*');
|
|
548
|
+
onEvent({ haId: eventHaId, event: frame.event, data, id: frame.id, timestamp: new Date().toISOString(), simulated: false });
|
|
549
|
+
if (frame.retry) backoff = Math.max(1000, Math.min(frame.retry, 30000));
|
|
550
|
+
}
|
|
551
|
+
if (!closed && announcedConnected) {
|
|
552
|
+
onEvent({
|
|
553
|
+
haId: haId || '*',
|
|
554
|
+
event: 'DISCONNECTED',
|
|
555
|
+
data: { items: [] },
|
|
556
|
+
timestamp: new Date().toISOString(),
|
|
557
|
+
connection: { synthetic: true, reason: 'stream-ended' },
|
|
558
|
+
simulated: false
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
} catch (error) {
|
|
562
|
+
if (!closed && error.name !== 'AbortError') {
|
|
563
|
+
if (error.status === 429 && Number(error.retryAfter) > 0) {
|
|
564
|
+
backoff = Math.max(backoff, Math.min(Number(error.retryAfter) * 1000, 300000));
|
|
565
|
+
}
|
|
566
|
+
onEvent({
|
|
567
|
+
haId: haId || '*',
|
|
568
|
+
event: 'DISCONNECTED',
|
|
569
|
+
data: { items: [] },
|
|
570
|
+
timestamp: new Date().toISOString(),
|
|
571
|
+
connection: { synthetic: true, reason: error.code || error.name || 'error' },
|
|
572
|
+
simulated: false
|
|
573
|
+
});
|
|
574
|
+
onError(error);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if (!closed) {
|
|
578
|
+
await sleep(backoff);
|
|
579
|
+
backoff = Math.min(backoff * 2, 30000);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
run().catch(error => { if (!closed) onError(error); });
|
|
584
|
+
return close;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
close() {
|
|
588
|
+
for (const close of [...this.eventClosers]) close();
|
|
589
|
+
this.capabilityCache.clear();
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
module.exports = {
|
|
594
|
+
HomeConnectClient,
|
|
595
|
+
ENVIRONMENTS,
|
|
596
|
+
API_MEDIA_TYPE,
|
|
597
|
+
READ_OPERATIONS,
|
|
598
|
+
retryDelay,
|
|
599
|
+
timedSignal
|
|
600
|
+
};
|
package/lib/constants.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const API_MEDIA_TYPE = 'application/vnd.bsh.sdk.v1+json';
|
|
4
|
+
const DEFAULT_SCOPE = 'IdentifyAppliance';
|
|
5
|
+
|
|
6
|
+
const ENVIRONMENTS = Object.freeze({
|
|
7
|
+
production: 'https://api.home-connect.com',
|
|
8
|
+
china: 'https://api.home-connect.cn',
|
|
9
|
+
'official-simulator': 'https://simulator.home-connect.com'
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const READ_OPERATIONS = Object.freeze([
|
|
13
|
+
'snapshot',
|
|
14
|
+
'capabilities',
|
|
15
|
+
'appliances',
|
|
16
|
+
'appliance',
|
|
17
|
+
'status',
|
|
18
|
+
'status-item',
|
|
19
|
+
'settings',
|
|
20
|
+
'setting',
|
|
21
|
+
'all-programs',
|
|
22
|
+
'programs',
|
|
23
|
+
'program',
|
|
24
|
+
'active-program',
|
|
25
|
+
'active-options',
|
|
26
|
+
'active-option',
|
|
27
|
+
'selected-program',
|
|
28
|
+
'selected-options',
|
|
29
|
+
'selected-option',
|
|
30
|
+
'commands',
|
|
31
|
+
'images',
|
|
32
|
+
'image',
|
|
33
|
+
'maps',
|
|
34
|
+
'map',
|
|
35
|
+
'map-occupancy-grid',
|
|
36
|
+
'map-layout',
|
|
37
|
+
'map-cleaning-status',
|
|
38
|
+
'map-detected-objects',
|
|
39
|
+
'map-detected-object',
|
|
40
|
+
'map-rooms',
|
|
41
|
+
'map-room',
|
|
42
|
+
'map-zones',
|
|
43
|
+
'map-zone',
|
|
44
|
+
'map-robot-position',
|
|
45
|
+
'map-config-restrictions',
|
|
46
|
+
'map-cleaning-summaries',
|
|
47
|
+
'map-cleaning-summary'
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const WRITE_ACTIONS = Object.freeze([
|
|
51
|
+
'start-program',
|
|
52
|
+
'stop-program',
|
|
53
|
+
'select-program',
|
|
54
|
+
'set-setting',
|
|
55
|
+
'command',
|
|
56
|
+
'set-active-option',
|
|
57
|
+
'set-selected-option'
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
module.exports = {
|
|
61
|
+
API_MEDIA_TYPE,
|
|
62
|
+
DEFAULT_SCOPE,
|
|
63
|
+
ENVIRONMENTS,
|
|
64
|
+
READ_OPERATIONS,
|
|
65
|
+
WRITE_ACTIONS
|
|
66
|
+
};
|