homebridge-salus-cloud 0.1.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/LICENSE +176 -0
- package/README.md +112 -0
- package/config.schema.json +148 -0
- package/dist/deviceCatalog.d.ts +10 -0
- package/dist/deviceCatalog.js +237 -0
- package/dist/deviceCatalog.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/platform.d.ts +27 -0
- package/dist/platform.js +329 -0
- package/dist/platform.js.map +1 -0
- package/dist/platformAccessory.d.ts +62 -0
- package/dist/platformAccessory.js +820 -0
- package/dist/platformAccessory.js.map +1 -0
- package/dist/propertyUtils.d.ts +15 -0
- package/dist/propertyUtils.js +185 -0
- package/dist/propertyUtils.js.map +1 -0
- package/dist/salusCloudClient.d.ts +52 -0
- package/dist/salusCloudClient.js +1447 -0
- package/dist/salusCloudClient.js.map +1 -0
- package/dist/settings.d.ts +2 -0
- package/dist/settings.js +3 -0
- package/dist/settings.js.map +1 -0
- package/dist/types.d.ts +56 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +52 -0
- package/scripts/publish.sh +117 -0
|
@@ -0,0 +1,1447 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-use-before-define */
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { baseNameForProperty, normalizeModelName, parseBooleanLike, parseNumberLike } from './propertyUtils.js';
|
|
4
|
+
class HttpStatusError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
responseBody;
|
|
7
|
+
constructor(message, status, responseBody) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.responseBody = responseBody;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
14
|
+
const DEFAULT_RETRY_BASE_DELAY_MS = 750;
|
|
15
|
+
const MAX_RETRY_DELAY_MS = 60_000;
|
|
16
|
+
const MAX_PREFERRED_WRITE_CACHE_SIZE = 2_000;
|
|
17
|
+
const DEFAULT_COGNITO_REGION = 'eu-central-1';
|
|
18
|
+
const DEFAULT_COGNITO_CLIENT_ID = '4pk5efh3v84g5dav43imsv4fbj';
|
|
19
|
+
const DEFAULT_EU_SERVICE_API_HOST = 'https://service-api.eu.premium.salusconnect.io';
|
|
20
|
+
const DEFAULT_US_SERVICE_API_HOST = 'https://service-api.us.premium.salusconnect.io';
|
|
21
|
+
const FALLBACK_US_SERVICE_API_HOST = 'https://service-api.us.salusconnect.io';
|
|
22
|
+
const FALLBACK_EU_SERVICE_API_HOST = 'https://service-api.eu.salusconnect.io';
|
|
23
|
+
const COGNITO_INITIATE_AUTH_TARGET = 'AWSCognitoIdentityProviderService.InitiateAuth';
|
|
24
|
+
const ACCEPT_LANGUAGE = 'en-US,en;q=0.9,en;q=0.8';
|
|
25
|
+
const SESSION_REFRESH_SAFETY_MS = 60_000;
|
|
26
|
+
const STATUS_ALLOW_PATH_FALLBACK = new Set([404, 405, 426]);
|
|
27
|
+
const STATUS_ALLOW_WRITE_SHAPE_FALLBACK = new Set([400, 404, 405, 409, 415, 422]);
|
|
28
|
+
const DEFAULT_EXPECTED_STATUSES = [200, 201, 202, 204];
|
|
29
|
+
const RETRIABLE_ERROR_CODES = new Set([
|
|
30
|
+
'ABORT_ERR',
|
|
31
|
+
'ECONNRESET',
|
|
32
|
+
'ECONNREFUSED',
|
|
33
|
+
'EAI_AGAIN',
|
|
34
|
+
'ENOTFOUND',
|
|
35
|
+
'ETIMEDOUT',
|
|
36
|
+
'EHOSTUNREACH',
|
|
37
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
38
|
+
'UND_ERR_SOCKET',
|
|
39
|
+
]);
|
|
40
|
+
const METADATA_FIELD_NAMES = new Set([
|
|
41
|
+
'id',
|
|
42
|
+
'key',
|
|
43
|
+
'dsn',
|
|
44
|
+
'name',
|
|
45
|
+
'model',
|
|
46
|
+
'product_name',
|
|
47
|
+
'oem_model',
|
|
48
|
+
'type',
|
|
49
|
+
'status',
|
|
50
|
+
'online',
|
|
51
|
+
'created_at',
|
|
52
|
+
'updated_at',
|
|
53
|
+
'timestamp',
|
|
54
|
+
'state',
|
|
55
|
+
'shadow',
|
|
56
|
+
'reported',
|
|
57
|
+
'desired',
|
|
58
|
+
'version',
|
|
59
|
+
'gateway',
|
|
60
|
+
'gateway_id',
|
|
61
|
+
'user_id',
|
|
62
|
+
'occupant_id',
|
|
63
|
+
]);
|
|
64
|
+
export class SalusCloudClient {
|
|
65
|
+
log;
|
|
66
|
+
config;
|
|
67
|
+
session = null;
|
|
68
|
+
authRequestInFlight = null;
|
|
69
|
+
requestTimeoutMs;
|
|
70
|
+
maxRetries;
|
|
71
|
+
retryBaseDelayMs;
|
|
72
|
+
verboseLogging;
|
|
73
|
+
allowInsecureTls;
|
|
74
|
+
serviceApiBaseCandidates;
|
|
75
|
+
cognitoEndpoint;
|
|
76
|
+
cognitoClientId;
|
|
77
|
+
companyCode;
|
|
78
|
+
activeServiceApiBaseUrl = null;
|
|
79
|
+
propertyCacheByDsn = new Map();
|
|
80
|
+
deviceIdToDsn = new Map();
|
|
81
|
+
deviceKeyToDsn = new Map();
|
|
82
|
+
preferredShadowVariantDescription = null;
|
|
83
|
+
preferredWriteAttemptByKey = new Map();
|
|
84
|
+
insecureTlsInFlight = 0;
|
|
85
|
+
insecureTlsPreviousValue;
|
|
86
|
+
insecureTlsHadPreviousValue = false;
|
|
87
|
+
constructor(log, config) {
|
|
88
|
+
this.log = log;
|
|
89
|
+
this.config = config;
|
|
90
|
+
this.requestTimeoutMs = Math.max(5_000, Math.round((config.requestTimeoutSeconds ?? 30) * 1_000));
|
|
91
|
+
this.maxRetries = Math.max(0, Math.floor(config.maxRetries ?? DEFAULT_MAX_RETRIES));
|
|
92
|
+
this.retryBaseDelayMs = Math.max(250, Math.floor(config.retryBaseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS));
|
|
93
|
+
this.verboseLogging = config.verboseLogging ?? false;
|
|
94
|
+
this.allowInsecureTls = config.allowInsecureTls ?? false;
|
|
95
|
+
this.serviceApiBaseCandidates = buildServiceApiBaseCandidates(config.region, config.apiHost, config.apiVersionPreference);
|
|
96
|
+
const cognitoRegion = normalizeNonEmptyString(config.cognitoRegion) ?? DEFAULT_COGNITO_REGION;
|
|
97
|
+
this.cognitoClientId = normalizeNonEmptyString(config.cognitoClientId) ?? DEFAULT_COGNITO_CLIENT_ID;
|
|
98
|
+
this.cognitoEndpoint = `https://cognito-idp.${cognitoRegion}.amazonaws.com/`;
|
|
99
|
+
this.companyCode = normalizeNonEmptyString(config.companyCode) ?? null;
|
|
100
|
+
if (this.allowInsecureTls) {
|
|
101
|
+
this.log.warn('TLS certificate validation is disabled for Salus cloud requests (allowInsecureTls=true).');
|
|
102
|
+
}
|
|
103
|
+
if (this.verboseLogging) {
|
|
104
|
+
this.log.debug(`Salus service-api candidates: ${this.serviceApiBaseCandidates.join(', ')}`);
|
|
105
|
+
this.log.debug(`Salus Cognito endpoint: ${this.cognitoEndpoint}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
getCloudBaseUrl() {
|
|
109
|
+
return this.activeServiceApiBaseUrl ?? this.serviceApiBaseCandidates[0];
|
|
110
|
+
}
|
|
111
|
+
async listDevices() {
|
|
112
|
+
const payload = await this.requestServiceJsonWithPathFallback(['/devices/', '/devices'], {
|
|
113
|
+
method: 'GET',
|
|
114
|
+
auth: true,
|
|
115
|
+
});
|
|
116
|
+
const devices = parseDevices(payload);
|
|
117
|
+
this.rebuildDeviceIndex(devices);
|
|
118
|
+
const inlineShadows = parseDeviceShadows(payload, this.deviceIdToDsn, this.deviceKeyToDsn);
|
|
119
|
+
if (inlineShadows.size > 0) {
|
|
120
|
+
this.replacePropertyCache(inlineShadows);
|
|
121
|
+
if (this.verboseLogging) {
|
|
122
|
+
this.log.debug(`Hydrated property cache from /devices response for ${inlineShadows.size} device(s)`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const shadowPayload = await this.fetchDeviceShadows(devices);
|
|
126
|
+
if (shadowPayload.size > 0) {
|
|
127
|
+
this.mergeIntoPropertyCache(shadowPayload);
|
|
128
|
+
if (this.verboseLogging) {
|
|
129
|
+
this.log.debug(`Hydrated property cache from devices/device_shadows for ${shadowPayload.size} device(s)`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
else if (devices.length > 0 && inlineShadows.size === 0) {
|
|
133
|
+
this.log.warn('No property payload was returned by devices/device_shadows. Accessory states may stay stale until Salus API responds.');
|
|
134
|
+
}
|
|
135
|
+
if (this.verboseLogging) {
|
|
136
|
+
this.log.debug(`Salus cloud returned ${devices.length} device(s)`);
|
|
137
|
+
}
|
|
138
|
+
return devices;
|
|
139
|
+
}
|
|
140
|
+
async listProperties(dsn) {
|
|
141
|
+
const cached = this.propertyCacheByDsn.get(dsn);
|
|
142
|
+
if (cached) {
|
|
143
|
+
return cached;
|
|
144
|
+
}
|
|
145
|
+
const shadows = await this.fetchDeviceShadows([], [dsn]);
|
|
146
|
+
const fromFetch = shadows.get(dsn);
|
|
147
|
+
if (fromFetch) {
|
|
148
|
+
this.propertyCacheByDsn.set(dsn, fromFetch);
|
|
149
|
+
return fromFetch;
|
|
150
|
+
}
|
|
151
|
+
if (this.verboseLogging) {
|
|
152
|
+
this.log.debug(`No cloud property shadow found for dsn=${dsn}. Returning empty property map.`);
|
|
153
|
+
}
|
|
154
|
+
return new Map();
|
|
155
|
+
}
|
|
156
|
+
async setDatapoint(dsn, propertyName, value) {
|
|
157
|
+
const writeCacheKey = `${dsn}:${propertyName}`;
|
|
158
|
+
const preferredAttempt = this.preferredWriteAttemptByKey.get(writeCacheKey) ?? null;
|
|
159
|
+
const attempts = prioritizeByDescription(this.buildWriteAttempts(dsn, propertyName, value), preferredAttempt);
|
|
160
|
+
const failures = [];
|
|
161
|
+
let fatalError;
|
|
162
|
+
for (const attempt of attempts) {
|
|
163
|
+
try {
|
|
164
|
+
await this.requestServiceJson(attempt.path, {
|
|
165
|
+
method: attempt.method,
|
|
166
|
+
body: attempt.body,
|
|
167
|
+
auth: true,
|
|
168
|
+
expectedStatuses: DEFAULT_EXPECTED_STATUSES,
|
|
169
|
+
});
|
|
170
|
+
this.updateCachedProperty(dsn, propertyName, value);
|
|
171
|
+
this.rememberPreferredWriteAttempt(writeCacheKey, attempt.description);
|
|
172
|
+
if (this.verboseLogging) {
|
|
173
|
+
this.log.debug(`Write succeeded via ${attempt.description}`);
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
const failureMessage = error instanceof HttpStatusError
|
|
179
|
+
? `${attempt.description} -> HTTP ${error.status}`
|
|
180
|
+
: `${attempt.description} -> ${asErrorMessage(error)}`;
|
|
181
|
+
failures.push(failureMessage);
|
|
182
|
+
if (error instanceof HttpStatusError && STATUS_ALLOW_WRITE_SHAPE_FALLBACK.has(error.status)) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (error instanceof HttpStatusError && STATUS_ALLOW_PATH_FALLBACK.has(error.status)) {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (isRetriableFailure(error)) {
|
|
189
|
+
fatalError = error;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
fatalError = error;
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (fatalError) {
|
|
197
|
+
throw new Error(`Failed to write property ${propertyName} on ${dsn}. Fatal error: ${asErrorMessage(fatalError)}. Attempts: ${failures.join(' | ')}`);
|
|
198
|
+
}
|
|
199
|
+
throw new Error(`Failed to write property ${propertyName} on ${dsn}. Attempts: ${failures.join(' | ')}`);
|
|
200
|
+
}
|
|
201
|
+
rememberPreferredWriteAttempt(cacheKey, description) {
|
|
202
|
+
this.preferredWriteAttemptByKey.delete(cacheKey);
|
|
203
|
+
this.preferredWriteAttemptByKey.set(cacheKey, description);
|
|
204
|
+
while (this.preferredWriteAttemptByKey.size > MAX_PREFERRED_WRITE_CACHE_SIZE) {
|
|
205
|
+
const oldestKey = this.preferredWriteAttemptByKey.keys().next().value;
|
|
206
|
+
if (!oldestKey) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
this.preferredWriteAttemptByKey.delete(oldestKey);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
rebuildDeviceIndex(devices) {
|
|
213
|
+
this.deviceIdToDsn.clear();
|
|
214
|
+
this.deviceKeyToDsn.clear();
|
|
215
|
+
for (const device of devices) {
|
|
216
|
+
this.deviceIdToDsn.set(device.id, device.dsn);
|
|
217
|
+
if (device.key) {
|
|
218
|
+
this.deviceKeyToDsn.set(device.key, device.dsn);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
replacePropertyCache(next) {
|
|
223
|
+
this.propertyCacheByDsn.clear();
|
|
224
|
+
for (const [dsn, properties] of next) {
|
|
225
|
+
this.propertyCacheByDsn.set(dsn, properties);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
mergeIntoPropertyCache(next) {
|
|
229
|
+
for (const [dsn, properties] of next) {
|
|
230
|
+
const existing = this.propertyCacheByDsn.get(dsn);
|
|
231
|
+
if (!existing) {
|
|
232
|
+
this.propertyCacheByDsn.set(dsn, properties);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
mergePropertyMaps(existing, properties);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
async fetchDeviceShadows(devices, preferredDsns) {
|
|
239
|
+
const dsns = [...new Set((preferredDsns ?? devices.map((device) => device.dsn)).filter((value) => value.trim() !== ''))];
|
|
240
|
+
const ids = [...new Set(devices.map((device) => device.id).filter((value) => value.trim() !== ''))];
|
|
241
|
+
const keys = [...new Set(devices.map((device) => device.key).filter((value) => Boolean(value && value.trim() !== '')))];
|
|
242
|
+
const variants = [
|
|
243
|
+
{
|
|
244
|
+
method: 'GET',
|
|
245
|
+
path: '/devices/device_shadows',
|
|
246
|
+
description: 'GET /devices/device_shadows',
|
|
247
|
+
},
|
|
248
|
+
];
|
|
249
|
+
if (dsns.length > 0) {
|
|
250
|
+
variants.push({
|
|
251
|
+
method: 'GET',
|
|
252
|
+
path: `/devices/device_shadows?dsns=${encodeURIComponent(dsns.join(','))}`,
|
|
253
|
+
description: 'GET /devices/device_shadows?dsns=',
|
|
254
|
+
});
|
|
255
|
+
variants.push({
|
|
256
|
+
method: 'POST',
|
|
257
|
+
path: '/devices/device_shadows',
|
|
258
|
+
body: { dsns },
|
|
259
|
+
description: 'POST /devices/device_shadows {dsns}',
|
|
260
|
+
});
|
|
261
|
+
variants.push({
|
|
262
|
+
method: 'POST',
|
|
263
|
+
path: '/devices/device_shadows',
|
|
264
|
+
body: {
|
|
265
|
+
devices: dsns.map((dsn) => ({ dsn })),
|
|
266
|
+
},
|
|
267
|
+
description: 'POST /devices/device_shadows {devices:[{dsn}]}',
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
if (ids.length > 0) {
|
|
271
|
+
variants.push({
|
|
272
|
+
method: 'GET',
|
|
273
|
+
path: `/devices/device_shadows?device_ids=${encodeURIComponent(ids.join(','))}`,
|
|
274
|
+
description: 'GET /devices/device_shadows?device_ids=',
|
|
275
|
+
});
|
|
276
|
+
variants.push({
|
|
277
|
+
method: 'POST',
|
|
278
|
+
path: '/devices/device_shadows',
|
|
279
|
+
body: { device_ids: ids },
|
|
280
|
+
description: 'POST /devices/device_shadows {device_ids}',
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
if (keys.length > 0) {
|
|
284
|
+
variants.push({
|
|
285
|
+
method: 'GET',
|
|
286
|
+
path: `/devices/device_shadows?device_keys=${encodeURIComponent(keys.join(','))}`,
|
|
287
|
+
description: 'GET /devices/device_shadows?device_keys=',
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
const orderedVariants = prioritizeByDescription(variants, this.preferredShadowVariantDescription);
|
|
291
|
+
let lastRecoverableError;
|
|
292
|
+
for (const variant of orderedVariants) {
|
|
293
|
+
try {
|
|
294
|
+
const payload = await this.requestServiceJsonWithPathFallback([variant.path], {
|
|
295
|
+
method: variant.method,
|
|
296
|
+
body: variant.body,
|
|
297
|
+
auth: true,
|
|
298
|
+
});
|
|
299
|
+
const map = parseDeviceShadows(payload, this.deviceIdToDsn, this.deviceKeyToDsn);
|
|
300
|
+
if (map.size > 0) {
|
|
301
|
+
this.preferredShadowVariantDescription = variant.description;
|
|
302
|
+
return map;
|
|
303
|
+
}
|
|
304
|
+
if (this.verboseLogging) {
|
|
305
|
+
this.log.debug(`Shadow variant ${variant.description} returned no device properties.`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
if (error instanceof HttpStatusError && STATUS_ALLOW_PATH_FALLBACK.has(error.status)) {
|
|
310
|
+
if (this.verboseLogging) {
|
|
311
|
+
this.log.debug(`Skipping unsupported shadow variant ${variant.description}: HTTP ${error.status}`);
|
|
312
|
+
}
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (error instanceof HttpStatusError && STATUS_ALLOW_WRITE_SHAPE_FALLBACK.has(error.status)) {
|
|
316
|
+
if (this.verboseLogging) {
|
|
317
|
+
this.log.debug(`Skipping incompatible shadow payload variant ${variant.description}: HTTP ${error.status}`);
|
|
318
|
+
}
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (isRetriableFailure(error)) {
|
|
322
|
+
lastRecoverableError = error;
|
|
323
|
+
if (this.verboseLogging) {
|
|
324
|
+
this.log.debug(`Retryable shadow request failure for ${variant.description}: ${asErrorMessage(error)}`);
|
|
325
|
+
}
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (this.verboseLogging) {
|
|
329
|
+
this.log.debug(`Fatal shadow request failure for ${variant.description}: ${asErrorMessage(error)}`);
|
|
330
|
+
}
|
|
331
|
+
throw error;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (lastRecoverableError) {
|
|
335
|
+
throw new Error(`Unable to fetch Salus device shadows: ${asErrorMessage(lastRecoverableError)}`);
|
|
336
|
+
}
|
|
337
|
+
return new Map();
|
|
338
|
+
}
|
|
339
|
+
buildWriteAttempts(dsn, propertyName, value) {
|
|
340
|
+
const deviceId = this.findDeviceIdByDsn(dsn);
|
|
341
|
+
const deviceKey = this.findDeviceKeyByDsn(dsn);
|
|
342
|
+
const references = [
|
|
343
|
+
{ description: 'dsn', ref: { dsn } },
|
|
344
|
+
{ description: 'device_dsn', ref: { device_dsn: dsn } },
|
|
345
|
+
];
|
|
346
|
+
if (deviceId) {
|
|
347
|
+
references.push({ description: 'device_id', ref: { device_id: deviceId } });
|
|
348
|
+
references.push({ description: 'id', ref: { id: deviceId } });
|
|
349
|
+
}
|
|
350
|
+
if (deviceKey) {
|
|
351
|
+
references.push({ description: 'device_key', ref: { device_key: deviceKey } });
|
|
352
|
+
references.push({ description: 'key', ref: { key: deviceKey } });
|
|
353
|
+
}
|
|
354
|
+
const attempts = [];
|
|
355
|
+
for (const reference of references) {
|
|
356
|
+
attempts.push({
|
|
357
|
+
method: 'POST',
|
|
358
|
+
path: '/devices/bulk',
|
|
359
|
+
body: {
|
|
360
|
+
devices: [
|
|
361
|
+
{
|
|
362
|
+
...reference.ref,
|
|
363
|
+
shadow: {
|
|
364
|
+
[propertyName]: value,
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
],
|
|
368
|
+
},
|
|
369
|
+
description: `POST /devices/bulk with shadow object (${reference.description})`,
|
|
370
|
+
});
|
|
371
|
+
attempts.push({
|
|
372
|
+
method: 'POST',
|
|
373
|
+
path: '/devices/bulk',
|
|
374
|
+
body: {
|
|
375
|
+
devices: [
|
|
376
|
+
{
|
|
377
|
+
...reference.ref,
|
|
378
|
+
properties: [
|
|
379
|
+
{
|
|
380
|
+
name: propertyName,
|
|
381
|
+
value,
|
|
382
|
+
},
|
|
383
|
+
],
|
|
384
|
+
},
|
|
385
|
+
],
|
|
386
|
+
},
|
|
387
|
+
description: `POST /devices/bulk with properties[] (${reference.description})`,
|
|
388
|
+
});
|
|
389
|
+
attempts.push({
|
|
390
|
+
method: 'POST',
|
|
391
|
+
path: '/devices/bulk',
|
|
392
|
+
body: {
|
|
393
|
+
devices: [
|
|
394
|
+
{
|
|
395
|
+
...reference.ref,
|
|
396
|
+
datapoints: [
|
|
397
|
+
{
|
|
398
|
+
name: propertyName,
|
|
399
|
+
value,
|
|
400
|
+
},
|
|
401
|
+
],
|
|
402
|
+
},
|
|
403
|
+
],
|
|
404
|
+
},
|
|
405
|
+
description: `POST /devices/bulk with datapoints[] (${reference.description})`,
|
|
406
|
+
});
|
|
407
|
+
attempts.push({
|
|
408
|
+
method: 'PATCH',
|
|
409
|
+
path: '/devices/device_shadows',
|
|
410
|
+
body: {
|
|
411
|
+
...reference.ref,
|
|
412
|
+
shadow: {
|
|
413
|
+
[propertyName]: value,
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
description: `PATCH /devices/device_shadows with shadow object (${reference.description})`,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
attempts.push({
|
|
420
|
+
method: 'POST',
|
|
421
|
+
path: '/devices/device_shadows',
|
|
422
|
+
body: {
|
|
423
|
+
device_shadows: [
|
|
424
|
+
{
|
|
425
|
+
dsn,
|
|
426
|
+
shadow: {
|
|
427
|
+
[propertyName]: value,
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
],
|
|
431
|
+
},
|
|
432
|
+
description: 'POST /devices/device_shadows with device_shadows[]',
|
|
433
|
+
});
|
|
434
|
+
attempts.push({
|
|
435
|
+
method: 'POST',
|
|
436
|
+
path: '/devices/bulk',
|
|
437
|
+
body: {
|
|
438
|
+
updates: [
|
|
439
|
+
{
|
|
440
|
+
dsn,
|
|
441
|
+
property_name: propertyName,
|
|
442
|
+
value,
|
|
443
|
+
},
|
|
444
|
+
],
|
|
445
|
+
},
|
|
446
|
+
description: 'POST /devices/bulk with updates[]',
|
|
447
|
+
});
|
|
448
|
+
attempts.push({
|
|
449
|
+
method: 'POST',
|
|
450
|
+
path: '/devices/bulk',
|
|
451
|
+
body: {
|
|
452
|
+
dsn,
|
|
453
|
+
property_name: propertyName,
|
|
454
|
+
value,
|
|
455
|
+
},
|
|
456
|
+
description: 'POST /devices/bulk with flat payload',
|
|
457
|
+
});
|
|
458
|
+
return attempts;
|
|
459
|
+
}
|
|
460
|
+
findDeviceIdByDsn(dsn) {
|
|
461
|
+
for (const [id, mappedDsn] of this.deviceIdToDsn) {
|
|
462
|
+
if (mappedDsn === dsn) {
|
|
463
|
+
return id;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return undefined;
|
|
467
|
+
}
|
|
468
|
+
findDeviceKeyByDsn(dsn) {
|
|
469
|
+
for (const [key, mappedDsn] of this.deviceKeyToDsn) {
|
|
470
|
+
if (mappedDsn === dsn) {
|
|
471
|
+
return key;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return undefined;
|
|
475
|
+
}
|
|
476
|
+
updateCachedProperty(dsn, propertyName, value) {
|
|
477
|
+
let map = this.propertyCacheByDsn.get(dsn);
|
|
478
|
+
if (!map) {
|
|
479
|
+
map = new Map();
|
|
480
|
+
this.propertyCacheByDsn.set(dsn, map);
|
|
481
|
+
}
|
|
482
|
+
map.set(propertyName, {
|
|
483
|
+
name: propertyName,
|
|
484
|
+
baseName: baseNameForProperty(propertyName),
|
|
485
|
+
value,
|
|
486
|
+
updatedAt: new Date().toISOString(),
|
|
487
|
+
raw: {
|
|
488
|
+
name: propertyName,
|
|
489
|
+
value,
|
|
490
|
+
},
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
async ensureLoggedIn() {
|
|
494
|
+
if (this.session && Date.now() + SESSION_REFRESH_SAFETY_MS < this.session.expiresAtEpochMs) {
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (this.session?.refreshToken) {
|
|
498
|
+
try {
|
|
499
|
+
await this.refreshSession();
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
this.log.warn(`Failed to refresh Salus cloud session; performing full login (${asErrorMessage(error)})`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
await this.login();
|
|
507
|
+
}
|
|
508
|
+
async login() {
|
|
509
|
+
if (this.authRequestInFlight) {
|
|
510
|
+
return this.authRequestInFlight;
|
|
511
|
+
}
|
|
512
|
+
this.authRequestInFlight = (async () => {
|
|
513
|
+
this.session = null;
|
|
514
|
+
this.session = await this.authenticateWithPassword();
|
|
515
|
+
this.log.info('Authenticated with Salus cloud');
|
|
516
|
+
})();
|
|
517
|
+
try {
|
|
518
|
+
await this.authRequestInFlight;
|
|
519
|
+
}
|
|
520
|
+
finally {
|
|
521
|
+
this.authRequestInFlight = null;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
async refreshSession() {
|
|
525
|
+
if (this.authRequestInFlight) {
|
|
526
|
+
return this.authRequestInFlight;
|
|
527
|
+
}
|
|
528
|
+
this.authRequestInFlight = (async () => {
|
|
529
|
+
const refreshToken = this.session?.refreshToken;
|
|
530
|
+
if (!refreshToken) {
|
|
531
|
+
this.session = null;
|
|
532
|
+
this.session = await this.authenticateWithPassword();
|
|
533
|
+
this.log.info('Authenticated with Salus cloud');
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
try {
|
|
537
|
+
this.session = await this.authenticateWithRefreshToken(refreshToken);
|
|
538
|
+
}
|
|
539
|
+
catch (error) {
|
|
540
|
+
this.session = null;
|
|
541
|
+
throw error;
|
|
542
|
+
}
|
|
543
|
+
if (this.verboseLogging) {
|
|
544
|
+
this.log.debug('Refreshed Salus cloud session token');
|
|
545
|
+
}
|
|
546
|
+
})();
|
|
547
|
+
try {
|
|
548
|
+
await this.authRequestInFlight;
|
|
549
|
+
}
|
|
550
|
+
finally {
|
|
551
|
+
this.authRequestInFlight = null;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
async authenticateWithPassword() {
|
|
555
|
+
const email = this.config.email?.trim();
|
|
556
|
+
const password = this.config.password;
|
|
557
|
+
if (!email || !password) {
|
|
558
|
+
throw new Error('Salus credentials are missing. Set email and password in plugin config.');
|
|
559
|
+
}
|
|
560
|
+
const payload = await this.cognitoInitiateAuth({
|
|
561
|
+
AuthFlow: 'USER_PASSWORD_AUTH',
|
|
562
|
+
ClientId: this.cognitoClientId,
|
|
563
|
+
AuthParameters: {
|
|
564
|
+
USERNAME: email,
|
|
565
|
+
PASSWORD: password,
|
|
566
|
+
},
|
|
567
|
+
});
|
|
568
|
+
const tokens = parseCognitoTokens(payload);
|
|
569
|
+
if (!tokens) {
|
|
570
|
+
throw new Error('Cognito login succeeded but token payload was missing AccessToken/RefreshToken.');
|
|
571
|
+
}
|
|
572
|
+
return tokens;
|
|
573
|
+
}
|
|
574
|
+
async authenticateWithRefreshToken(refreshToken) {
|
|
575
|
+
const payload = await this.cognitoInitiateAuth({
|
|
576
|
+
AuthFlow: 'REFRESH_TOKEN_AUTH',
|
|
577
|
+
ClientId: this.cognitoClientId,
|
|
578
|
+
AuthParameters: {
|
|
579
|
+
REFRESH_TOKEN: refreshToken,
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
const refreshed = parseCognitoTokens(payload, refreshToken);
|
|
583
|
+
if (!refreshed) {
|
|
584
|
+
throw new Error('Cognito refresh response did not include AccessToken.');
|
|
585
|
+
}
|
|
586
|
+
return refreshed;
|
|
587
|
+
}
|
|
588
|
+
async cognitoInitiateAuth(body) {
|
|
589
|
+
const totalAttempts = this.maxRetries + 1;
|
|
590
|
+
let lastError;
|
|
591
|
+
for (let attempt = 1; attempt <= totalAttempts; attempt++) {
|
|
592
|
+
try {
|
|
593
|
+
const response = await this.fetchWithTimeout(this.cognitoEndpoint, {
|
|
594
|
+
method: 'POST',
|
|
595
|
+
headers: {
|
|
596
|
+
Accept: 'application/x-amz-json-1.1',
|
|
597
|
+
'Content-Type': 'application/x-amz-json-1.1',
|
|
598
|
+
'X-Amz-Target': COGNITO_INITIATE_AUTH_TARGET,
|
|
599
|
+
},
|
|
600
|
+
body: JSON.stringify(body),
|
|
601
|
+
});
|
|
602
|
+
if (!response.ok) {
|
|
603
|
+
const responseBody = await safeReadText(response);
|
|
604
|
+
const cognitoMessage = parseCognitoErrorMessage(responseBody);
|
|
605
|
+
const cognitoError = new HttpStatusError(`Cognito auth failed (HTTP ${response.status}): ${cognitoMessage}`, response.status, responseBody);
|
|
606
|
+
if (isRetriableStatus(response.status) && attempt < totalAttempts) {
|
|
607
|
+
lastError = cognitoError;
|
|
608
|
+
await this.retryDelay(attempt, cognitoError.message);
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
throw cognitoError;
|
|
612
|
+
}
|
|
613
|
+
return await response.json();
|
|
614
|
+
}
|
|
615
|
+
catch (error) {
|
|
616
|
+
if (!this.allowInsecureTls && isTlsCertificateError(error)) {
|
|
617
|
+
const message = `${asErrorMessage(error)}. If Salus cloud certificate is invalid, set "allowInsecureTls": true in plugin config.`;
|
|
618
|
+
throw new Error(message);
|
|
619
|
+
}
|
|
620
|
+
if (isRetriableFailure(error) && attempt < totalAttempts) {
|
|
621
|
+
lastError = error;
|
|
622
|
+
await this.retryDelay(attempt, asErrorMessage(error));
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
throw error;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
throw new Error(`Cognito auth failed after retries: ${asErrorMessage(lastError)}`);
|
|
629
|
+
}
|
|
630
|
+
async requestServiceJsonWithPathFallback(paths, options) {
|
|
631
|
+
let lastPathError;
|
|
632
|
+
for (const path of paths) {
|
|
633
|
+
try {
|
|
634
|
+
return await this.requestServiceJson(path, options);
|
|
635
|
+
}
|
|
636
|
+
catch (error) {
|
|
637
|
+
if (error instanceof HttpStatusError && STATUS_ALLOW_PATH_FALLBACK.has(error.status)) {
|
|
638
|
+
lastPathError = error;
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
throw error;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
if (lastPathError) {
|
|
645
|
+
throw lastPathError;
|
|
646
|
+
}
|
|
647
|
+
throw new Error(`No valid path candidates for request: ${paths.join(', ')}`);
|
|
648
|
+
}
|
|
649
|
+
async requestServiceJson(path, options) {
|
|
650
|
+
const authRequired = options.auth ?? true;
|
|
651
|
+
if (authRequired) {
|
|
652
|
+
await this.ensureLoggedIn();
|
|
653
|
+
}
|
|
654
|
+
const expectedStatuses = options.expectedStatuses ?? DEFAULT_EXPECTED_STATUSES;
|
|
655
|
+
const totalAttempts = this.maxRetries + 1;
|
|
656
|
+
let hasRefreshedSessionAfter401 = false;
|
|
657
|
+
let lastError = new Error(`No Salus cloud response received for ${options.method} ${path}`);
|
|
658
|
+
for (let attempt = 1; attempt <= totalAttempts; attempt++) {
|
|
659
|
+
const orderedBaseUrls = this.getOrderedServiceApiBaseUrls();
|
|
660
|
+
let sawRetriableFailure = false;
|
|
661
|
+
let sawDefinitiveFailure = false;
|
|
662
|
+
let definitiveError;
|
|
663
|
+
let lastRetriableError;
|
|
664
|
+
for (const baseUrl of orderedBaseUrls) {
|
|
665
|
+
try {
|
|
666
|
+
const headers = this.buildServiceHeaders(authRequired);
|
|
667
|
+
const body = options.body !== undefined ? JSON.stringify(options.body) : undefined;
|
|
668
|
+
if (body !== undefined) {
|
|
669
|
+
headers['Content-Type'] = 'application/json';
|
|
670
|
+
}
|
|
671
|
+
const url = buildServiceUrl(baseUrl, path, options.method === 'GET');
|
|
672
|
+
const response = await this.fetchWithTimeout(url, {
|
|
673
|
+
method: options.method,
|
|
674
|
+
headers,
|
|
675
|
+
body,
|
|
676
|
+
});
|
|
677
|
+
if (response.status === 401 && authRequired && (options.allow401Refresh ?? true) && !hasRefreshedSessionAfter401) {
|
|
678
|
+
hasRefreshedSessionAfter401 = true;
|
|
679
|
+
this.log.warn(`Salus cloud returned 401 for ${options.method} ${path}. Refreshing session token and retrying.`);
|
|
680
|
+
await this.refreshSession();
|
|
681
|
+
lastError = new HttpStatusError('Unauthorized', 401, '');
|
|
682
|
+
sawRetriableFailure = true;
|
|
683
|
+
lastRetriableError = lastError;
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
if (!expectedStatuses.includes(response.status)) {
|
|
687
|
+
const responseText = await safeReadText(response);
|
|
688
|
+
const message = `HTTP ${response.status} ${response.statusText} on ${options.method} ${path}`;
|
|
689
|
+
const statusError = new HttpStatusError(responseText ? `${message} :: ${responseText}` : message, response.status, responseText);
|
|
690
|
+
if (STATUS_ALLOW_PATH_FALLBACK.has(response.status)) {
|
|
691
|
+
lastError = statusError;
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
if (isRetriableStatus(response.status)) {
|
|
695
|
+
lastError = statusError;
|
|
696
|
+
sawRetriableFailure = true;
|
|
697
|
+
lastRetriableError = statusError;
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
lastError = statusError;
|
|
701
|
+
sawDefinitiveFailure = true;
|
|
702
|
+
if (!definitiveError) {
|
|
703
|
+
definitiveError = statusError;
|
|
704
|
+
}
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
this.activeServiceApiBaseUrl = baseUrl;
|
|
708
|
+
if (response.status === 204) {
|
|
709
|
+
return undefined;
|
|
710
|
+
}
|
|
711
|
+
return await parseResponseBody(response);
|
|
712
|
+
}
|
|
713
|
+
catch (error) {
|
|
714
|
+
if (!this.allowInsecureTls && isTlsCertificateError(error)) {
|
|
715
|
+
const message = `${asErrorMessage(error)}. If Salus cloud certificate is invalid, set "allowInsecureTls": true in plugin config.`;
|
|
716
|
+
throw new Error(message);
|
|
717
|
+
}
|
|
718
|
+
if (error instanceof HttpStatusError) {
|
|
719
|
+
lastError = error;
|
|
720
|
+
if (isRetriableStatus(error.status)) {
|
|
721
|
+
sawRetriableFailure = true;
|
|
722
|
+
lastRetriableError = error;
|
|
723
|
+
}
|
|
724
|
+
else if (!STATUS_ALLOW_PATH_FALLBACK.has(error.status)) {
|
|
725
|
+
sawDefinitiveFailure = true;
|
|
726
|
+
if (!definitiveError) {
|
|
727
|
+
definitiveError = error;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
if (isRetriableError(error)) {
|
|
733
|
+
lastError = error;
|
|
734
|
+
sawRetriableFailure = true;
|
|
735
|
+
lastRetriableError = error;
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
throw error;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
if (sawDefinitiveFailure) {
|
|
742
|
+
throw definitiveError ?? lastError;
|
|
743
|
+
}
|
|
744
|
+
if (attempt < totalAttempts && sawRetriableFailure) {
|
|
745
|
+
await this.retryDelay(attempt, asErrorMessage(lastRetriableError ?? lastError));
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
if (lastError) {
|
|
749
|
+
throw lastError;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
throw new Error(`Unexpected request state for ${options.method} ${path}`);
|
|
753
|
+
}
|
|
754
|
+
buildServiceHeaders(authRequired) {
|
|
755
|
+
const headers = {
|
|
756
|
+
Accept: 'application/json',
|
|
757
|
+
'Accept-Language': ACCEPT_LANGUAGE,
|
|
758
|
+
'User-Agent': 'homebridge-salus-cloud/2026',
|
|
759
|
+
};
|
|
760
|
+
if (!authRequired) {
|
|
761
|
+
return headers;
|
|
762
|
+
}
|
|
763
|
+
if (!this.session) {
|
|
764
|
+
throw new Error('Missing Salus cloud session while building authenticated request.');
|
|
765
|
+
}
|
|
766
|
+
headers.Authorization = `Bearer ${this.session.accessToken}`;
|
|
767
|
+
headers['x-access-token'] = this.session.accessToken;
|
|
768
|
+
headers['x-auth-token'] = this.session.idToken || this.session.accessToken;
|
|
769
|
+
if (this.companyCode) {
|
|
770
|
+
headers['x-company-code'] = this.companyCode;
|
|
771
|
+
}
|
|
772
|
+
return headers;
|
|
773
|
+
}
|
|
774
|
+
getOrderedServiceApiBaseUrls() {
|
|
775
|
+
const dedupe = new Set();
|
|
776
|
+
const ordered = [];
|
|
777
|
+
if (this.activeServiceApiBaseUrl) {
|
|
778
|
+
dedupe.add(this.activeServiceApiBaseUrl);
|
|
779
|
+
ordered.push(this.activeServiceApiBaseUrl);
|
|
780
|
+
}
|
|
781
|
+
for (const candidate of this.serviceApiBaseCandidates) {
|
|
782
|
+
if (dedupe.has(candidate)) {
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
dedupe.add(candidate);
|
|
786
|
+
ordered.push(candidate);
|
|
787
|
+
}
|
|
788
|
+
return ordered;
|
|
789
|
+
}
|
|
790
|
+
async retryDelay(attempt, reason) {
|
|
791
|
+
const baseDelay = Math.min(MAX_RETRY_DELAY_MS, this.retryBaseDelayMs * (2 ** (attempt - 1)));
|
|
792
|
+
const jitter = 0.85 + (Math.random() * 0.3);
|
|
793
|
+
const delayMs = Math.max(250, Math.round(baseDelay * jitter));
|
|
794
|
+
this.log.warn(`Retrying Salus cloud request in ${delayMs}ms (attempt ${attempt + 1}/${this.maxRetries + 1}): ${reason}`);
|
|
795
|
+
await sleep(delayMs);
|
|
796
|
+
}
|
|
797
|
+
async fetchWithTimeout(url, init) {
|
|
798
|
+
const controller = new AbortController();
|
|
799
|
+
const timer = setTimeout(() => {
|
|
800
|
+
controller.abort();
|
|
801
|
+
}, this.requestTimeoutMs);
|
|
802
|
+
if (this.allowInsecureTls && this.insecureTlsInFlight === 0) {
|
|
803
|
+
this.insecureTlsHadPreviousValue = Object.prototype.hasOwnProperty.call(process.env, 'NODE_TLS_REJECT_UNAUTHORIZED');
|
|
804
|
+
this.insecureTlsPreviousValue = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
|
|
805
|
+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
|
806
|
+
}
|
|
807
|
+
if (this.allowInsecureTls) {
|
|
808
|
+
this.insecureTlsInFlight += 1;
|
|
809
|
+
}
|
|
810
|
+
try {
|
|
811
|
+
return await fetch(url, {
|
|
812
|
+
...init,
|
|
813
|
+
signal: controller.signal,
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
finally {
|
|
817
|
+
clearTimeout(timer);
|
|
818
|
+
if (this.allowInsecureTls) {
|
|
819
|
+
this.insecureTlsInFlight = Math.max(0, this.insecureTlsInFlight - 1);
|
|
820
|
+
if (this.insecureTlsInFlight === 0) {
|
|
821
|
+
if (this.insecureTlsHadPreviousValue) {
|
|
822
|
+
if (this.insecureTlsPreviousValue === undefined) {
|
|
823
|
+
delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
|
|
824
|
+
}
|
|
825
|
+
else {
|
|
826
|
+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = this.insecureTlsPreviousValue;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
else {
|
|
830
|
+
delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
function buildServiceApiBaseCandidates(region, overrideHost, versionPreference) {
|
|
838
|
+
const normalizedOverride = normalizeNonEmptyString(overrideHost);
|
|
839
|
+
if (normalizedOverride) {
|
|
840
|
+
const normalizedUrl = normalizeUrl(normalizedOverride);
|
|
841
|
+
if (/\/api\/v[12]$/i.test(normalizedUrl)) {
|
|
842
|
+
return [normalizedUrl];
|
|
843
|
+
}
|
|
844
|
+
return buildApiVersionCandidates(normalizedUrl, versionPreference);
|
|
845
|
+
}
|
|
846
|
+
const hosts = region === 'us'
|
|
847
|
+
? [DEFAULT_US_SERVICE_API_HOST, FALLBACK_US_SERVICE_API_HOST]
|
|
848
|
+
: [DEFAULT_EU_SERVICE_API_HOST, FALLBACK_EU_SERVICE_API_HOST];
|
|
849
|
+
return hosts.flatMap((host) => buildApiVersionCandidates(host, versionPreference));
|
|
850
|
+
}
|
|
851
|
+
function buildApiVersionCandidates(baseHost, preference) {
|
|
852
|
+
const normalized = normalizeUrl(baseHost).replace(/\/+$/, '');
|
|
853
|
+
const versions = preference === 'v1'
|
|
854
|
+
? ['v1', 'v2']
|
|
855
|
+
: preference === 'v2'
|
|
856
|
+
? ['v2', 'v1']
|
|
857
|
+
: ['v1', 'v2'];
|
|
858
|
+
return versions.map((version) => `${normalized}/api/${version}`);
|
|
859
|
+
}
|
|
860
|
+
function buildServiceUrl(baseUrl, path, addTimestamp) {
|
|
861
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
862
|
+
const joined = `${baseUrl}${normalizedPath}`;
|
|
863
|
+
return addTimestamp ? withTimestampQuery(joined) : joined;
|
|
864
|
+
}
|
|
865
|
+
function withTimestampQuery(url) {
|
|
866
|
+
const separator = url.includes('?') ? '&' : '?';
|
|
867
|
+
return `${url}${separator}timestamp=${Date.now()}`;
|
|
868
|
+
}
|
|
869
|
+
function normalizeUrl(raw) {
|
|
870
|
+
const trimmed = raw.trim().replace(/\/+$/, '');
|
|
871
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
872
|
+
return trimmed;
|
|
873
|
+
}
|
|
874
|
+
return `https://${trimmed}`;
|
|
875
|
+
}
|
|
876
|
+
function normalizeNonEmptyString(value) {
|
|
877
|
+
if (!value) {
|
|
878
|
+
return undefined;
|
|
879
|
+
}
|
|
880
|
+
const trimmed = value.trim();
|
|
881
|
+
if (trimmed.length === 0) {
|
|
882
|
+
return undefined;
|
|
883
|
+
}
|
|
884
|
+
return trimmed;
|
|
885
|
+
}
|
|
886
|
+
function parseCognitoTokens(payload, refreshTokenFallback) {
|
|
887
|
+
const record = asRecord(payload);
|
|
888
|
+
if (!record) {
|
|
889
|
+
return undefined;
|
|
890
|
+
}
|
|
891
|
+
const challengeName = asString(record.ChallengeName);
|
|
892
|
+
if (challengeName) {
|
|
893
|
+
throw new Error(`Cognito challenge flow is not supported by this plugin: ${challengeName}`);
|
|
894
|
+
}
|
|
895
|
+
const authResult = asRecord(record.AuthenticationResult);
|
|
896
|
+
if (!authResult) {
|
|
897
|
+
return undefined;
|
|
898
|
+
}
|
|
899
|
+
const accessToken = asString(authResult.AccessToken);
|
|
900
|
+
const idToken = asString(authResult.IdToken) ?? accessToken;
|
|
901
|
+
const refreshToken = asString(authResult.RefreshToken) ?? refreshTokenFallback;
|
|
902
|
+
const tokenType = asString(authResult.TokenType) ?? 'Bearer';
|
|
903
|
+
const expiresInRaw = parseNumberLike(authResult.ExpiresIn);
|
|
904
|
+
const expiresInSeconds = expiresInRaw && Number.isFinite(expiresInRaw) ? Math.max(60, Math.floor(expiresInRaw)) : 3600;
|
|
905
|
+
if (!accessToken || !idToken || !refreshToken) {
|
|
906
|
+
return undefined;
|
|
907
|
+
}
|
|
908
|
+
return {
|
|
909
|
+
accessToken,
|
|
910
|
+
idToken,
|
|
911
|
+
refreshToken,
|
|
912
|
+
tokenType,
|
|
913
|
+
expiresAtEpochMs: Date.now() + (expiresInSeconds * 1_000),
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
function parseCognitoErrorMessage(responseBody) {
|
|
917
|
+
const trimmed = responseBody.trim();
|
|
918
|
+
if (!trimmed) {
|
|
919
|
+
return 'Unknown Cognito error';
|
|
920
|
+
}
|
|
921
|
+
try {
|
|
922
|
+
const parsed = JSON.parse(trimmed);
|
|
923
|
+
const directMessage = asString(parsed.message) ?? asString(parsed.Message);
|
|
924
|
+
if (directMessage) {
|
|
925
|
+
return directMessage;
|
|
926
|
+
}
|
|
927
|
+
const type = asString(parsed.__type) ?? asString(parsed.code);
|
|
928
|
+
if (type) {
|
|
929
|
+
return type;
|
|
930
|
+
}
|
|
931
|
+
return trimmed;
|
|
932
|
+
}
|
|
933
|
+
catch {
|
|
934
|
+
return trimmed;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
function parseDevices(payload) {
|
|
938
|
+
const root = asRecord(payload);
|
|
939
|
+
const candidates = [];
|
|
940
|
+
if (Array.isArray(payload)) {
|
|
941
|
+
candidates.push(payload);
|
|
942
|
+
}
|
|
943
|
+
if (root) {
|
|
944
|
+
const arrayKeys = ['devices', 'registered_nodes', 'nodes', 'results', 'data', 'list', 'device_list'];
|
|
945
|
+
for (const key of arrayKeys) {
|
|
946
|
+
const arr = asArray(root[key]);
|
|
947
|
+
if (arr) {
|
|
948
|
+
candidates.push(arr);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
// Some payloads return an object map keyed by device identifiers.
|
|
952
|
+
const mappedDevices = asRecord(root.devices);
|
|
953
|
+
if (mappedDevices) {
|
|
954
|
+
const values = Object.values(mappedDevices);
|
|
955
|
+
if (values.length > 0) {
|
|
956
|
+
candidates.push(values);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
const result = [];
|
|
961
|
+
const visitedEntries = new Set();
|
|
962
|
+
for (const candidate of candidates) {
|
|
963
|
+
for (const item of candidate) {
|
|
964
|
+
if (visitedEntries.has(item)) {
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
visitedEntries.add(item);
|
|
968
|
+
const rawItem = asRecord(item);
|
|
969
|
+
const deviceWrapper = rawItem?.device;
|
|
970
|
+
const normalized = asRecord(deviceWrapper) ?? rawItem;
|
|
971
|
+
if (!normalized) {
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
const dsn = asString(normalized.dsn)
|
|
975
|
+
?? asString(normalized.device_dsn)
|
|
976
|
+
?? asString(normalized.DSN);
|
|
977
|
+
if (!dsn) {
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
const key = asString(normalized.key) ?? asString(normalized.device_key);
|
|
981
|
+
const id = asString(normalized.id)
|
|
982
|
+
?? asString(normalized.device_id)
|
|
983
|
+
?? key
|
|
984
|
+
?? dsn;
|
|
985
|
+
const modelRaw = asString(normalized.oem_model)
|
|
986
|
+
?? asString(normalized.model)
|
|
987
|
+
?? asString(normalized.product_class)
|
|
988
|
+
?? asString(normalized.device_model)
|
|
989
|
+
?? '';
|
|
990
|
+
const model = normalizeModelName(modelRaw);
|
|
991
|
+
const displayName = deriveDeviceDisplayName(normalized, dsn, model);
|
|
992
|
+
const online = deriveOnlineState(normalized);
|
|
993
|
+
result.push({
|
|
994
|
+
id,
|
|
995
|
+
dsn,
|
|
996
|
+
key,
|
|
997
|
+
model,
|
|
998
|
+
name: displayName,
|
|
999
|
+
productName: asString(normalized.product_name),
|
|
1000
|
+
online,
|
|
1001
|
+
raw: normalized,
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
return dedupeDevicesByDsn(result);
|
|
1006
|
+
}
|
|
1007
|
+
function parseDeviceShadows(payload, deviceIdToDsn, deviceKeyToDsn) {
|
|
1008
|
+
const output = new Map();
|
|
1009
|
+
const queue = [payload];
|
|
1010
|
+
const visited = new Set();
|
|
1011
|
+
while (queue.length > 0) {
|
|
1012
|
+
const current = queue.shift();
|
|
1013
|
+
if (!current || typeof current !== 'object') {
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
if (visited.has(current)) {
|
|
1017
|
+
continue;
|
|
1018
|
+
}
|
|
1019
|
+
visited.add(current);
|
|
1020
|
+
if (Array.isArray(current)) {
|
|
1021
|
+
for (const entry of current) {
|
|
1022
|
+
queue.push(entry);
|
|
1023
|
+
}
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
const record = current;
|
|
1027
|
+
const dsn = inferShadowDsn(record, deviceIdToDsn, deviceKeyToDsn);
|
|
1028
|
+
if (dsn) {
|
|
1029
|
+
const propertyCandidates = [
|
|
1030
|
+
record.shadow,
|
|
1031
|
+
record.device_shadow,
|
|
1032
|
+
record.properties,
|
|
1033
|
+
record.property_values,
|
|
1034
|
+
record.datapoints,
|
|
1035
|
+
record.reported,
|
|
1036
|
+
record.desired,
|
|
1037
|
+
record.state,
|
|
1038
|
+
record.attrs,
|
|
1039
|
+
];
|
|
1040
|
+
const merged = new Map();
|
|
1041
|
+
for (const candidate of propertyCandidates) {
|
|
1042
|
+
const parsed = parseProperties(candidate);
|
|
1043
|
+
mergePropertyMaps(merged, parsed);
|
|
1044
|
+
}
|
|
1045
|
+
if (merged.size === 0) {
|
|
1046
|
+
// Some payloads provide a flat object map directly on device record.
|
|
1047
|
+
const parsedFromRecord = parseProperties(record);
|
|
1048
|
+
mergePropertyMaps(merged, parsedFromRecord);
|
|
1049
|
+
}
|
|
1050
|
+
if (merged.size > 0) {
|
|
1051
|
+
const existing = output.get(dsn);
|
|
1052
|
+
if (existing) {
|
|
1053
|
+
mergePropertyMaps(existing, merged);
|
|
1054
|
+
}
|
|
1055
|
+
else {
|
|
1056
|
+
output.set(dsn, merged);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
for (const value of Object.values(record)) {
|
|
1061
|
+
if (value && typeof value === 'object') {
|
|
1062
|
+
queue.push(value);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
return output;
|
|
1067
|
+
}
|
|
1068
|
+
function inferShadowDsn(record, deviceIdToDsn, deviceKeyToDsn) {
|
|
1069
|
+
const direct = asString(record.dsn)
|
|
1070
|
+
?? asString(record.device_dsn)
|
|
1071
|
+
?? asString(record.DSN);
|
|
1072
|
+
if (direct) {
|
|
1073
|
+
return direct;
|
|
1074
|
+
}
|
|
1075
|
+
const byId = asString(record.id)
|
|
1076
|
+
?? asString(record.device_id)
|
|
1077
|
+
?? asString(record.node_id);
|
|
1078
|
+
if (byId) {
|
|
1079
|
+
const found = deviceIdToDsn.get(byId);
|
|
1080
|
+
if (found) {
|
|
1081
|
+
return found;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
const byKey = asString(record.key)
|
|
1085
|
+
?? asString(record.device_key)
|
|
1086
|
+
?? asString(record.unique_hardware_id);
|
|
1087
|
+
if (byKey) {
|
|
1088
|
+
const found = deviceKeyToDsn.get(byKey);
|
|
1089
|
+
if (found) {
|
|
1090
|
+
return found;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
return undefined;
|
|
1094
|
+
}
|
|
1095
|
+
function parseProperties(payload) {
|
|
1096
|
+
const result = new Map();
|
|
1097
|
+
const root = asRecord(payload);
|
|
1098
|
+
const candidateArrays = [];
|
|
1099
|
+
if (Array.isArray(payload)) {
|
|
1100
|
+
candidateArrays.push(payload);
|
|
1101
|
+
}
|
|
1102
|
+
if (root) {
|
|
1103
|
+
const keys = ['properties', 'property', 'data', 'results', 'datapoints', 'list'];
|
|
1104
|
+
for (const key of keys) {
|
|
1105
|
+
const maybeArray = asArray(root[key]);
|
|
1106
|
+
if (maybeArray) {
|
|
1107
|
+
candidateArrays.push(maybeArray);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
for (const entries of candidateArrays) {
|
|
1112
|
+
for (const entry of entries) {
|
|
1113
|
+
const property = parsePropertyEntry(entry);
|
|
1114
|
+
if (property) {
|
|
1115
|
+
result.set(property.name, property);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
if (root) {
|
|
1120
|
+
parsePropertyObjectMap(root, result);
|
|
1121
|
+
}
|
|
1122
|
+
return result;
|
|
1123
|
+
}
|
|
1124
|
+
function parsePropertyObjectMap(record, output) {
|
|
1125
|
+
for (const [name, rawValue] of Object.entries(record)) {
|
|
1126
|
+
if (METADATA_FIELD_NAMES.has(name)) {
|
|
1127
|
+
continue;
|
|
1128
|
+
}
|
|
1129
|
+
const extracted = extractPropertyValue(rawValue);
|
|
1130
|
+
if (extracted === undefined) {
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
if (!looksLikePropertyName(name, rawValue)) {
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
output.set(name, {
|
|
1137
|
+
name,
|
|
1138
|
+
baseName: baseNameForProperty(name),
|
|
1139
|
+
value: extracted,
|
|
1140
|
+
updatedAt: extractPropertyTimestamp(rawValue),
|
|
1141
|
+
raw: asRecord(rawValue) ?? { value: rawValue },
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
function parsePropertyEntry(entry) {
|
|
1146
|
+
const asRoot = asRecord(entry);
|
|
1147
|
+
if (!asRoot) {
|
|
1148
|
+
return undefined;
|
|
1149
|
+
}
|
|
1150
|
+
const node = asRecord(asRoot.property) ?? asRoot;
|
|
1151
|
+
const name = asString(node.name)
|
|
1152
|
+
?? asString(node.property_name)
|
|
1153
|
+
?? asString(node.key);
|
|
1154
|
+
if (!name) {
|
|
1155
|
+
return undefined;
|
|
1156
|
+
}
|
|
1157
|
+
const datapoint = asRecord(node.datapoint);
|
|
1158
|
+
const lastDatapoint = asRecord(node.last_datapoint);
|
|
1159
|
+
const value = node.value
|
|
1160
|
+
?? datapoint?.value
|
|
1161
|
+
?? lastDatapoint?.value
|
|
1162
|
+
?? node.current_value
|
|
1163
|
+
?? extractPropertyValue(node);
|
|
1164
|
+
if (value === undefined) {
|
|
1165
|
+
return undefined;
|
|
1166
|
+
}
|
|
1167
|
+
const updatedAt = asString(node.updated_at)
|
|
1168
|
+
?? asString(node.updatedAt)
|
|
1169
|
+
?? asString(datapoint?.created_at)
|
|
1170
|
+
?? asString(lastDatapoint?.created_at)
|
|
1171
|
+
?? extractPropertyTimestamp(node);
|
|
1172
|
+
return {
|
|
1173
|
+
name,
|
|
1174
|
+
baseName: baseNameForProperty(name),
|
|
1175
|
+
value,
|
|
1176
|
+
updatedAt,
|
|
1177
|
+
raw: node,
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
function extractPropertyValue(rawValue) {
|
|
1181
|
+
if (rawValue === null) {
|
|
1182
|
+
return null;
|
|
1183
|
+
}
|
|
1184
|
+
if (typeof rawValue === 'string' || typeof rawValue === 'number' || typeof rawValue === 'boolean') {
|
|
1185
|
+
return rawValue;
|
|
1186
|
+
}
|
|
1187
|
+
const record = asRecord(rawValue);
|
|
1188
|
+
if (!record) {
|
|
1189
|
+
return undefined;
|
|
1190
|
+
}
|
|
1191
|
+
if ('value' in record) {
|
|
1192
|
+
return record.value;
|
|
1193
|
+
}
|
|
1194
|
+
if ('current_value' in record) {
|
|
1195
|
+
return record.current_value;
|
|
1196
|
+
}
|
|
1197
|
+
if ('reported' in record) {
|
|
1198
|
+
const reported = record.reported;
|
|
1199
|
+
if (typeof reported === 'string' || typeof reported === 'number' || typeof reported === 'boolean' || reported === null) {
|
|
1200
|
+
return reported;
|
|
1201
|
+
}
|
|
1202
|
+
const nestedReported = asRecord(reported);
|
|
1203
|
+
if (nestedReported && 'value' in nestedReported) {
|
|
1204
|
+
return nestedReported.value;
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
if ('desired' in record) {
|
|
1208
|
+
const desired = record.desired;
|
|
1209
|
+
if (typeof desired === 'string' || typeof desired === 'number' || typeof desired === 'boolean' || desired === null) {
|
|
1210
|
+
return desired;
|
|
1211
|
+
}
|
|
1212
|
+
const nestedDesired = asRecord(desired);
|
|
1213
|
+
if (nestedDesired && 'value' in nestedDesired) {
|
|
1214
|
+
return nestedDesired.value;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
if ('datapoint' in record) {
|
|
1218
|
+
const datapoint = asRecord(record.datapoint);
|
|
1219
|
+
if (datapoint && 'value' in datapoint) {
|
|
1220
|
+
return datapoint.value;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
if ('last_datapoint' in record) {
|
|
1224
|
+
const datapoint = asRecord(record.last_datapoint);
|
|
1225
|
+
if (datapoint && 'value' in datapoint) {
|
|
1226
|
+
return datapoint.value;
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
return undefined;
|
|
1230
|
+
}
|
|
1231
|
+
function extractPropertyTimestamp(rawValue) {
|
|
1232
|
+
const record = asRecord(rawValue);
|
|
1233
|
+
if (!record) {
|
|
1234
|
+
return undefined;
|
|
1235
|
+
}
|
|
1236
|
+
return asString(record.updated_at)
|
|
1237
|
+
?? asString(record.updatedAt)
|
|
1238
|
+
?? asString(record.created_at)
|
|
1239
|
+
?? asString(record.timestamp);
|
|
1240
|
+
}
|
|
1241
|
+
function looksLikePropertyName(name, rawValue) {
|
|
1242
|
+
if (/^\d+$/.test(name)) {
|
|
1243
|
+
return false;
|
|
1244
|
+
}
|
|
1245
|
+
if (name.includes(':')) {
|
|
1246
|
+
return true;
|
|
1247
|
+
}
|
|
1248
|
+
if (/^[A-Za-z][A-Za-z0-9_]+$/.test(name) && /[A-Z_]/.test(name)) {
|
|
1249
|
+
return true;
|
|
1250
|
+
}
|
|
1251
|
+
const extracted = extractPropertyValue(rawValue);
|
|
1252
|
+
if (typeof extracted === 'number' || typeof extracted === 'boolean') {
|
|
1253
|
+
return true;
|
|
1254
|
+
}
|
|
1255
|
+
if (typeof extracted === 'string') {
|
|
1256
|
+
if (parseBooleanLike(extracted) !== undefined) {
|
|
1257
|
+
return true;
|
|
1258
|
+
}
|
|
1259
|
+
if (parseNumberLike(extracted) !== undefined) {
|
|
1260
|
+
return true;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
return false;
|
|
1264
|
+
}
|
|
1265
|
+
function deriveDeviceDisplayName(record, dsn, model) {
|
|
1266
|
+
const candidates = [
|
|
1267
|
+
asString(record.product_name),
|
|
1268
|
+
asString(record.device_name),
|
|
1269
|
+
asString(record.name),
|
|
1270
|
+
asString(record.unique_hardware_id),
|
|
1271
|
+
asString(record.oem_model),
|
|
1272
|
+
];
|
|
1273
|
+
for (const candidate of candidates) {
|
|
1274
|
+
if (!candidate || candidate.trim() === '') {
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
1277
|
+
const trimmed = candidate.trim();
|
|
1278
|
+
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
|
1279
|
+
try {
|
|
1280
|
+
const parsed = JSON.parse(trimmed);
|
|
1281
|
+
const parsedName = asString(parsed.deviceName);
|
|
1282
|
+
if (parsedName) {
|
|
1283
|
+
return parsedName.trim().replaceAll('/', ' ');
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
catch {
|
|
1287
|
+
// Preserve raw candidate.
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
return trimmed.replaceAll('/', ' ');
|
|
1291
|
+
}
|
|
1292
|
+
return model || dsn;
|
|
1293
|
+
}
|
|
1294
|
+
function deriveOnlineState(record) {
|
|
1295
|
+
const boolFields = ['online', 'is_online', 'connected', 'reachable'];
|
|
1296
|
+
for (const field of boolFields) {
|
|
1297
|
+
const parsed = parseBooleanLike(record[field]);
|
|
1298
|
+
if (parsed !== undefined) {
|
|
1299
|
+
return parsed;
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
const status = asString(record.connection_status) ?? asString(record.status);
|
|
1303
|
+
if (!status) {
|
|
1304
|
+
return undefined;
|
|
1305
|
+
}
|
|
1306
|
+
const normalized = status.trim().toLowerCase();
|
|
1307
|
+
if (normalized.includes('online') || normalized.includes('connected')) {
|
|
1308
|
+
return true;
|
|
1309
|
+
}
|
|
1310
|
+
if (normalized.includes('offline') || normalized.includes('disconnected')) {
|
|
1311
|
+
return false;
|
|
1312
|
+
}
|
|
1313
|
+
return undefined;
|
|
1314
|
+
}
|
|
1315
|
+
function dedupeDevicesByDsn(devices) {
|
|
1316
|
+
const byDsn = new Map();
|
|
1317
|
+
for (const device of devices) {
|
|
1318
|
+
byDsn.set(device.dsn, device);
|
|
1319
|
+
}
|
|
1320
|
+
return [...byDsn.values()];
|
|
1321
|
+
}
|
|
1322
|
+
function mergePropertyMaps(target, source) {
|
|
1323
|
+
for (const [name, property] of source) {
|
|
1324
|
+
target.set(name, property);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
function prioritizeByDescription(items, preferredDescription) {
|
|
1328
|
+
if (!preferredDescription) {
|
|
1329
|
+
return items;
|
|
1330
|
+
}
|
|
1331
|
+
const preferred = items.find((item) => item.description === preferredDescription);
|
|
1332
|
+
if (!preferred) {
|
|
1333
|
+
return items;
|
|
1334
|
+
}
|
|
1335
|
+
return [preferred, ...items.filter((item) => item !== preferred)];
|
|
1336
|
+
}
|
|
1337
|
+
function isRetriableFailure(error) {
|
|
1338
|
+
if (error instanceof HttpStatusError) {
|
|
1339
|
+
return isRetriableStatus(error.status);
|
|
1340
|
+
}
|
|
1341
|
+
return isRetriableError(error);
|
|
1342
|
+
}
|
|
1343
|
+
function isRetriableStatus(status) {
|
|
1344
|
+
return status === 408 || status === 425 || status === 429 || status >= 500;
|
|
1345
|
+
}
|
|
1346
|
+
function isRetriableError(error) {
|
|
1347
|
+
if (!(error instanceof Error)) {
|
|
1348
|
+
return false;
|
|
1349
|
+
}
|
|
1350
|
+
const code = getErrorCauseCode(error)?.toUpperCase();
|
|
1351
|
+
if (code && RETRIABLE_ERROR_CODES.has(code)) {
|
|
1352
|
+
return true;
|
|
1353
|
+
}
|
|
1354
|
+
const message = error.message.toLowerCase();
|
|
1355
|
+
return message.includes('timed out')
|
|
1356
|
+
|| message.includes('network')
|
|
1357
|
+
|| message.includes('fetch failed')
|
|
1358
|
+
|| message.includes('aborted')
|
|
1359
|
+
|| message.includes('econnreset')
|
|
1360
|
+
|| message.includes('ecconnreset')
|
|
1361
|
+
|| message.includes('eai_again')
|
|
1362
|
+
|| message.includes('enotfound')
|
|
1363
|
+
|| message.includes('socket hang up');
|
|
1364
|
+
}
|
|
1365
|
+
function isTlsCertificateError(error) {
|
|
1366
|
+
if (!(error instanceof Error)) {
|
|
1367
|
+
return false;
|
|
1368
|
+
}
|
|
1369
|
+
const code = getErrorCauseCode(error);
|
|
1370
|
+
return code === 'CERT_HAS_EXPIRED'
|
|
1371
|
+
|| code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'
|
|
1372
|
+
|| code === 'SELF_SIGNED_CERT_IN_CHAIN'
|
|
1373
|
+
|| code === 'ERR_TLS_CERT_ALTNAME_INVALID';
|
|
1374
|
+
}
|
|
1375
|
+
function asErrorMessage(error) {
|
|
1376
|
+
if (error instanceof Error) {
|
|
1377
|
+
return error.message;
|
|
1378
|
+
}
|
|
1379
|
+
return String(error);
|
|
1380
|
+
}
|
|
1381
|
+
function getErrorCauseCode(error) {
|
|
1382
|
+
const directCode = error.code;
|
|
1383
|
+
if (typeof directCode === 'string') {
|
|
1384
|
+
return directCode;
|
|
1385
|
+
}
|
|
1386
|
+
if (error.cause instanceof Error) {
|
|
1387
|
+
const nestedCode = error.cause.code;
|
|
1388
|
+
if (typeof nestedCode === 'string') {
|
|
1389
|
+
return nestedCode;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
const cause = error.cause;
|
|
1393
|
+
const code = cause?.code;
|
|
1394
|
+
if (typeof code === 'string') {
|
|
1395
|
+
return code;
|
|
1396
|
+
}
|
|
1397
|
+
return undefined;
|
|
1398
|
+
}
|
|
1399
|
+
async function parseResponseBody(response) {
|
|
1400
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
1401
|
+
if (contentType.includes('application/json') || contentType.includes('application/x-amz-json-1.1')) {
|
|
1402
|
+
return await response.json();
|
|
1403
|
+
}
|
|
1404
|
+
const text = await response.text();
|
|
1405
|
+
if (!text) {
|
|
1406
|
+
return undefined;
|
|
1407
|
+
}
|
|
1408
|
+
try {
|
|
1409
|
+
return JSON.parse(text);
|
|
1410
|
+
}
|
|
1411
|
+
catch {
|
|
1412
|
+
return text;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
async function safeReadText(response) {
|
|
1416
|
+
try {
|
|
1417
|
+
return await response.text();
|
|
1418
|
+
}
|
|
1419
|
+
catch {
|
|
1420
|
+
return '';
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
function asRecord(value) {
|
|
1424
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
1425
|
+
return value;
|
|
1426
|
+
}
|
|
1427
|
+
return undefined;
|
|
1428
|
+
}
|
|
1429
|
+
function asArray(value) {
|
|
1430
|
+
if (Array.isArray(value)) {
|
|
1431
|
+
return value;
|
|
1432
|
+
}
|
|
1433
|
+
return undefined;
|
|
1434
|
+
}
|
|
1435
|
+
function asString(value) {
|
|
1436
|
+
if (typeof value === 'string') {
|
|
1437
|
+
return value;
|
|
1438
|
+
}
|
|
1439
|
+
if (typeof value === 'number' || typeof value === 'bigint') {
|
|
1440
|
+
return String(value);
|
|
1441
|
+
}
|
|
1442
|
+
return undefined;
|
|
1443
|
+
}
|
|
1444
|
+
function sleep(ms) {
|
|
1445
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1446
|
+
}
|
|
1447
|
+
//# sourceMappingURL=salusCloudClient.js.map
|