homey-api 3.17.0 → 3.17.2

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.
@@ -0,0 +1,294 @@
1
+ 'use strict';
2
+
3
+ const APIErrorHomeyOffline = require('../../APIErrorHomeyOffline');
4
+ const APIErrorHomeySubscriptionInactive = require('../../APIErrorHomeySubscriptionInactive');
5
+ const APIErrorHomeyInvalidSerialNumber = require('../../APIErrorHomeyInvalidSerialNumber');
6
+ const Util = require('../../Util');
7
+
8
+ class DiscoveryManager {
9
+ constructor(homey) {
10
+ this.homey = homey;
11
+ }
12
+
13
+ async discoverBaseUrl() {
14
+ const DISCOVERY_STRATEGIES = this.homey.constructor.DISCOVERY_STRATEGIES;
15
+ const urls = {};
16
+
17
+ if (this.homey.__strategies.includes(DISCOVERY_STRATEGIES.MDNS)) {
18
+ if (Util.isHTTPUnsecureSupported()) {
19
+ if (this.homey.__properties.mdnsFqdn) {
20
+ urls[DISCOVERY_STRATEGIES.MDNS] = `http://${this.homey.__properties.mdnsFqdn}`;
21
+ } else {
22
+ urls[DISCOVERY_STRATEGIES.MDNS] = `http://homey-${this.homey.id}.local`;
23
+ }
24
+ }
25
+ }
26
+
27
+ if (this.homey.__strategies.includes(DISCOVERY_STRATEGIES.LOCAL)) {
28
+ if (Util.isHTTPUnsecureSupported() && this.homey.__properties.localUrl) {
29
+ urls[DISCOVERY_STRATEGIES.LOCAL] = `${this.homey.__properties.localUrl}`;
30
+ }
31
+ }
32
+
33
+ if (this.homey.__strategies.includes(DISCOVERY_STRATEGIES.LOCAL_SECURE)) {
34
+ if (this.homey.__properties.localUrlSecure) {
35
+ urls[DISCOVERY_STRATEGIES.LOCAL_SECURE] = `${this.homey.__properties.localUrlSecure}`;
36
+ }
37
+ }
38
+
39
+ if (this.homey.__strategies.includes(DISCOVERY_STRATEGIES.CLOUD)) {
40
+ if (this.homey.__properties.remoteUrl) {
41
+ urls[DISCOVERY_STRATEGIES.CLOUD] = `${this.homey.__properties.remoteUrl}`;
42
+ }
43
+ }
44
+
45
+ if (this.homey.__strategies.includes(DISCOVERY_STRATEGIES.REMOTE_FORWARDED)) {
46
+ if (this.homey.__properties.remoteUrlForwarded) {
47
+ urls[DISCOVERY_STRATEGIES.REMOTE_FORWARDED] =
48
+ `${this.homey.__properties.remoteUrlForwarded}`;
49
+ }
50
+ }
51
+
52
+ if (!Object.keys(urls).length) {
53
+ throw new Error('No Discovery Strategies Available');
54
+ }
55
+
56
+ this.homey.__debug(`Discovery Strategies: ${Object.keys(urls).join(',')}`);
57
+
58
+ let resolve;
59
+ let reject;
60
+
61
+ const promise = new Promise((resolve_, reject_) => {
62
+ resolve = resolve_;
63
+ reject = reject_;
64
+ });
65
+
66
+ const pingAbortControllers = {};
67
+
68
+ const abortPendingPings = ({ exceptStrategyId } = {}) => {
69
+ Object.entries(pingAbortControllers).forEach(([strategyId, abortController]) => {
70
+ if (strategyId === exceptStrategyId) return;
71
+ if (abortController.signal.aborted) return;
72
+ abortController.abort('Discovery Completed');
73
+ });
74
+ };
75
+
76
+ const resolveAndAbortPendingPings = (result) => {
77
+ abortPendingPings({ exceptStrategyId: result.strategyId });
78
+ resolve(result);
79
+ return result;
80
+ };
81
+
82
+ const rejectAndAbortPendingPings = (error) => {
83
+ abortPendingPings();
84
+ reject(error);
85
+ };
86
+
87
+ const isSubscriptionError = (error) => {
88
+ return (
89
+ error instanceof APIErrorHomeySubscriptionInactive ||
90
+ error instanceof APIErrorHomeyInvalidSerialNumber
91
+ );
92
+ };
93
+
94
+ const normalizeDiscoveryError = (error) => {
95
+ if (error instanceof AggregateError) {
96
+ for (const err of error.errors) {
97
+ if (isSubscriptionError(err)) {
98
+ return err;
99
+ }
100
+ }
101
+
102
+ return new APIErrorHomeyOffline();
103
+ }
104
+
105
+ if (isSubscriptionError(error) || error instanceof APIErrorHomeyOffline) {
106
+ return error;
107
+ }
108
+
109
+ return new APIErrorHomeyOffline(error);
110
+ };
111
+
112
+ const ping = async (strategyId, timeout) => {
113
+ const baseUrl = urls[strategyId];
114
+ const abortController = new AbortController();
115
+ pingAbortControllers[strategyId] = abortController;
116
+
117
+ const response = await Util.fetch(
118
+ `${baseUrl}/api/manager/system/ping?id=${this.homey.id}`,
119
+ {
120
+ headers: {
121
+ 'X-Homey-ID': this.homey.id,
122
+ },
123
+ signal: abortController.signal,
124
+ },
125
+ timeout
126
+ );
127
+
128
+ const text = await response.text();
129
+
130
+ if (!response.ok) {
131
+ const responseContentType = response.headers.get('Content-Type');
132
+ let parsed;
133
+
134
+ if (responseContentType && responseContentType.toLowerCase().includes('application/json')) {
135
+ try {
136
+ parsed = JSON.parse(text);
137
+ // eslint-disable-next-line no-empty
138
+ } catch (err) {}
139
+ }
140
+
141
+ if (typeof parsed === 'object' && parsed !== null) {
142
+ if (
143
+ parsed.error &&
144
+ parsed.error === 'Subscription Inactive' &&
145
+ parsed.statusCode === 403
146
+ ) {
147
+ throw new APIErrorHomeySubscriptionInactive(parsed.error, parsed.statusCode);
148
+ } else if (
149
+ parsed.error &&
150
+ parsed.error === 'Invalid Serial Number' &&
151
+ parsed.statusCode === 403
152
+ ) {
153
+ throw new APIErrorHomeyInvalidSerialNumber(parsed.error, parsed.statusCode);
154
+ }
155
+ }
156
+
157
+ throw new Error(text || response.statusText);
158
+ }
159
+
160
+ if (text === 'false') {
161
+ throw new Error('Invalid Homey ID');
162
+ }
163
+
164
+ const homeyId = response.headers.get('X-Homey-ID');
165
+
166
+ if (homeyId && homeyId !== this.homey.id) {
167
+ throw new Error('Invalid Homey ID');
168
+ }
169
+
170
+ const homeyVersion = response.headers.get('X-Homey-Version');
171
+
172
+ return {
173
+ baseUrl,
174
+ strategyId,
175
+ homeyVersion,
176
+ };
177
+ };
178
+
179
+ const pings = {};
180
+
181
+ if (urls[DISCOVERY_STRATEGIES.LOCAL_SECURE]) {
182
+ pings[DISCOVERY_STRATEGIES.LOCAL_SECURE] = ping(
183
+ DISCOVERY_STRATEGIES.LOCAL_SECURE,
184
+ 1200
185
+ );
186
+ pings[DISCOVERY_STRATEGIES.LOCAL_SECURE].catch((err) => {
187
+ this.homey.__debug(
188
+ `Ping ${DISCOVERY_STRATEGIES.LOCAL_SECURE} Error:`,
189
+ err && err.message
190
+ );
191
+ this.homey.__debug(urls[DISCOVERY_STRATEGIES.LOCAL_SECURE]);
192
+ });
193
+ }
194
+
195
+ if (urls[DISCOVERY_STRATEGIES.LOCAL]) {
196
+ pings[DISCOVERY_STRATEGIES.LOCAL] = ping(DISCOVERY_STRATEGIES.LOCAL, 1000);
197
+ pings[DISCOVERY_STRATEGIES.LOCAL].catch((err) =>
198
+ this.homey.__debug(`Ping ${DISCOVERY_STRATEGIES.LOCAL} Error:`, err && err.message)
199
+ );
200
+ }
201
+
202
+ if (urls[DISCOVERY_STRATEGIES.MDNS]) {
203
+ pings[DISCOVERY_STRATEGIES.MDNS] = ping(DISCOVERY_STRATEGIES.MDNS, 3000);
204
+ pings[DISCOVERY_STRATEGIES.MDNS].catch((err) =>
205
+ this.homey.__debug(`Ping ${DISCOVERY_STRATEGIES.MDNS} Error:`, err && err.message)
206
+ );
207
+ }
208
+
209
+ if (urls[DISCOVERY_STRATEGIES.CLOUD]) {
210
+ pings[DISCOVERY_STRATEGIES.CLOUD] = ping(DISCOVERY_STRATEGIES.CLOUD, 5000);
211
+ pings[DISCOVERY_STRATEGIES.CLOUD].catch((err) =>
212
+ this.homey.__debug(`Ping ${DISCOVERY_STRATEGIES.CLOUD} Error:`, err && err.message)
213
+ );
214
+ }
215
+
216
+ if (urls[DISCOVERY_STRATEGIES.REMOTE_FORWARDED]) {
217
+ pings[DISCOVERY_STRATEGIES.REMOTE_FORWARDED] = ping(
218
+ DISCOVERY_STRATEGIES.REMOTE_FORWARDED,
219
+ 2000
220
+ );
221
+ pings[DISCOVERY_STRATEGIES.REMOTE_FORWARDED].catch((err) =>
222
+ this.homey.__debug(
223
+ `Ping ${DISCOVERY_STRATEGIES.REMOTE_FORWARDED} Error:`,
224
+ err && err.message
225
+ )
226
+ );
227
+ }
228
+
229
+ const withCloudFallback = (primaryPromise) => {
230
+ return primaryPromise.catch((error) => {
231
+ if (pings[DISCOVERY_STRATEGIES.CLOUD]) {
232
+ return pings[DISCOVERY_STRATEGIES.CLOUD];
233
+ }
234
+
235
+ throw new APIErrorHomeyOffline(error);
236
+ });
237
+ };
238
+
239
+ let selectedRoutePromise;
240
+
241
+ if (pings[DISCOVERY_STRATEGIES.LOCAL_SECURE]) {
242
+ selectedRoutePromise = pings[DISCOVERY_STRATEGIES.LOCAL_SECURE]
243
+ .catch((error) => {
244
+ const fallbackPromises = [];
245
+
246
+ if (pings[DISCOVERY_STRATEGIES.LOCAL]) {
247
+ fallbackPromises.push(pings[DISCOVERY_STRATEGIES.LOCAL]);
248
+ }
249
+
250
+ if (pings[DISCOVERY_STRATEGIES.REMOTE_FORWARDED]) {
251
+ fallbackPromises.push(pings[DISCOVERY_STRATEGIES.REMOTE_FORWARDED]);
252
+ }
253
+
254
+ if (pings[DISCOVERY_STRATEGIES.MDNS]) {
255
+ fallbackPromises.push(pings[DISCOVERY_STRATEGIES.MDNS]);
256
+ }
257
+
258
+ if (pings[DISCOVERY_STRATEGIES.CLOUD]) {
259
+ fallbackPromises.push(pings[DISCOVERY_STRATEGIES.CLOUD]);
260
+ }
261
+
262
+ if (isSubscriptionError(error)) {
263
+ throw error;
264
+ }
265
+
266
+ if (!fallbackPromises.length) {
267
+ throw new APIErrorHomeyOffline();
268
+ }
269
+
270
+ return Promise.any(fallbackPromises);
271
+ });
272
+ } else if (pings[DISCOVERY_STRATEGIES.LOCAL]) {
273
+ selectedRoutePromise = withCloudFallback(pings[DISCOVERY_STRATEGIES.LOCAL]);
274
+ } else if (pings[DISCOVERY_STRATEGIES.MDNS]) {
275
+ selectedRoutePromise = withCloudFallback(pings[DISCOVERY_STRATEGIES.MDNS]);
276
+ } else if (pings[DISCOVERY_STRATEGIES.REMOTE_FORWARDED]) {
277
+ selectedRoutePromise = withCloudFallback(pings[DISCOVERY_STRATEGIES.REMOTE_FORWARDED]);
278
+ } else if (pings[DISCOVERY_STRATEGIES.CLOUD]) {
279
+ selectedRoutePromise = pings[DISCOVERY_STRATEGIES.CLOUD];
280
+ } else {
281
+ selectedRoutePromise = Promise.reject(new APIErrorHomeyOffline());
282
+ }
283
+
284
+ selectedRoutePromise
285
+ .then((result) => resolveAndAbortPendingPings(result))
286
+ .catch((error) => {
287
+ rejectAndAbortPendingPings(normalizeDiscoveryError(error));
288
+ });
289
+
290
+ return promise;
291
+ }
292
+ }
293
+
294
+ module.exports = DiscoveryManager;
@@ -1,9 +1,6 @@
1
1
  'use strict';
2
2
 
3
3
  const SocketIOClient = require('socket.io-client');
4
- const APIErrorHomeyOffline = require('../APIErrorHomeyOffline');
5
- const APIErrorHomeySubscriptionInactive = require('../APIErrorHomeySubscriptionInactive');
6
- const APIErrorHomeyInvalidSerialNumber = require('../APIErrorHomeyInvalidSerialNumber');
7
4
  const Util = require('../Util');
8
5
  const HomeyAPI = require('./HomeyAPI');
9
6
  const HomeyAPIError = require('./HomeyAPIError');
@@ -15,6 +12,7 @@ const ManagerFlowToken = require('./HomeyAPIV3/ManagerFlowToken');
15
12
  const ManagerInsights = require('./HomeyAPIV3/ManagerInsights');
16
13
  const ManagerUsers = require('./HomeyAPIV3/ManagerUsers');
17
14
  const ManagerZones = require('./HomeyAPIV3/ManagerZones');
15
+ const DiscoveryManager = require('./HomeyAPIV3/DiscoveryManager');
18
16
  const Manager = require('./HomeyAPIV3/Manager');
19
17
 
20
18
  /**
@@ -119,6 +117,8 @@ class HomeyAPIV3 extends HomeyAPI {
119
117
  writable: true,
120
118
  });
121
119
 
120
+ this.__discoveryManager = new DiscoveryManager(this);
121
+
122
122
  this.generateManagersFromSpecification();
123
123
  }
124
124
 
@@ -128,9 +128,10 @@ class HomeyAPIV3 extends HomeyAPI {
128
128
  get baseUrl() {
129
129
  return (async () => {
130
130
  if (!this.__baseUrlPromise) {
131
- this.__baseUrlPromise = this.discoverBaseUrl().then(({ baseUrl }) => {
131
+ this.__baseUrlPromise = (async () => {
132
+ const { baseUrl } = await this.discoverBaseUrl();
132
133
  return baseUrl;
133
- });
134
+ })();
134
135
  this.__baseUrlPromise.catch(() => {});
135
136
  }
136
137
 
@@ -191,331 +192,25 @@ class HomeyAPIV3 extends HomeyAPI {
191
192
  */
192
193
 
193
194
  async discoverBaseUrl() {
194
- const urls = {};
195
-
196
- if (this.__strategies.includes(HomeyAPI.DISCOVERY_STRATEGIES.MDNS)) {
197
- if (Util.isHTTPUnsecureSupported()) {
198
- urls[HomeyAPI.DISCOVERY_STRATEGIES.MDNS] = `http://homey-${this.id}.local`;
199
- }
200
- }
201
-
202
- if (this.__strategies.includes(HomeyAPI.DISCOVERY_STRATEGIES.LOCAL)) {
203
- if (Util.isHTTPUnsecureSupported() && this.__properties.localUrl) {
204
- urls[HomeyAPI.DISCOVERY_STRATEGIES.LOCAL] = `${this.__properties.localUrl}`;
205
- }
206
- }
207
-
208
- if (this.__strategies.includes(HomeyAPI.DISCOVERY_STRATEGIES.LOCAL_SECURE)) {
209
- if (this.__properties.localUrlSecure) {
210
- urls[HomeyAPI.DISCOVERY_STRATEGIES.LOCAL_SECURE] = `${this.__properties.localUrlSecure}`;
211
- }
212
- }
213
-
214
- if (this.__strategies.includes(HomeyAPI.DISCOVERY_STRATEGIES.CLOUD)) {
215
- if (this.__properties.remoteUrl) {
216
- urls[HomeyAPI.DISCOVERY_STRATEGIES.CLOUD] = `${this.__properties.remoteUrl}`;
217
- }
218
- }
195
+ const discoveryResult = await this.__discoveryManager.discoverBaseUrl();
196
+ const result = this.__applyDiscoveryResult(discoveryResult);
197
+ return result;
198
+ }
219
199
 
220
- if (this.__strategies.includes(HomeyAPI.DISCOVERY_STRATEGIES.REMOTE_FORWARDED)) {
221
- if (this.__properties.remoteUrlForwarded) {
222
- urls[HomeyAPI.DISCOVERY_STRATEGIES.REMOTE_FORWARDED] =
223
- `${this.__properties.remoteUrlForwarded}`;
224
- }
225
- }
200
+ __applyDiscoveryResult({ baseUrl, strategyId, homeyVersion }) {
201
+ this.__baseUrl = baseUrl;
202
+ this.__baseUrlPromise = Promise.resolve(baseUrl);
203
+ this.__strategyId = strategyId;
226
204
 
227
- if (!Object.keys(urls).length) {
228
- throw new Error('No Discovery Strategies Available');
205
+ if (homeyVersion != null) {
206
+ this.version = homeyVersion;
229
207
  }
230
208
 
231
- this.__debug(`Discovery Strategies: ${Object.keys(urls).join(',')}`);
232
-
233
- // Create the returned Promise
234
- let resolve;
235
- let reject;
236
-
237
- const promise = new Promise((resolve_, reject_) => {
238
- resolve = resolve_;
239
- reject = reject_;
240
- });
241
-
242
- promise
243
- .then(({ baseUrl, strategyId }) => {
244
- this.__baseUrl = baseUrl;
245
- this.__strategyId = strategyId;
246
- })
247
- .catch(() => {});
248
-
249
- // Ping method
250
- const ping = async (strategyId, timeout) => {
251
- const baseUrl = urls[strategyId];
252
-
253
- const response = await Util.fetch(
254
- `${baseUrl}/api/manager/system/ping?id=${this.id}`,
255
- {
256
- headers: {
257
- 'X-Homey-ID': this.id,
258
- },
259
- },
260
- timeout
261
- );
262
-
263
- const text = await response.text();
264
-
265
- if (!response.ok) {
266
- const responseContentType = response.headers.get('Content-Type');
267
- let parsed;
268
-
269
- if (responseContentType && responseContentType.toLowerCase().includes('application/json')) {
270
- try {
271
- parsed = JSON.parse(text);
272
- // eslint-disable-next-line no-empty
273
- } catch (err) {}
274
- }
275
-
276
- if (typeof parsed === 'object' && parsed !== null) {
277
- if (
278
- parsed.error &&
279
- parsed.error === 'Subscription Inactive' &&
280
- parsed.statusCode === 403
281
- ) {
282
- throw new APIErrorHomeySubscriptionInactive(parsed.error, parsed.statusCode);
283
- } else if (
284
- parsed.error &&
285
- parsed.error === 'Invalid Serial Number' &&
286
- parsed.statusCode === 403
287
- ) {
288
- throw new APIErrorHomeyInvalidSerialNumber(parsed.error, parsed.statusCode);
289
- }
290
- }
291
-
292
- throw new Error(text || response.statusText);
293
- }
294
-
295
- if (text === 'false') {
296
- throw new Error('Invalid Homey ID');
297
- }
298
-
299
- const homeyId = response.headers.get('X-Homey-ID');
300
-
301
- if (homeyId && homeyId !== this.id) {
302
- throw new Error('Invalid Homey ID'); // TODO: Add to Homey Connect
303
- }
304
-
305
- // Set the version that Homey told us.
306
- // It's the absolute truth, because the Cloud API may be behind.
307
- const homeyVersion = response.headers.get('X-Homey-Version');
308
-
309
- if (homeyVersion !== this.version) {
310
- this.version = homeyVersion;
311
- }
312
-
313
- return {
314
- baseUrl,
315
- strategyId,
316
- };
209
+ return {
210
+ baseUrl,
211
+ strategyId,
212
+ homeyVersion,
317
213
  };
318
-
319
- const pings = {};
320
-
321
- // Ping localSecure (https://xxx-xxx-xxx-xx.homey.homeylocal.com)
322
- if (urls[HomeyAPI.DISCOVERY_STRATEGIES.LOCAL_SECURE]) {
323
- pings[HomeyAPI.DISCOVERY_STRATEGIES.LOCAL_SECURE] = ping(
324
- HomeyAPI.DISCOVERY_STRATEGIES.LOCAL_SECURE,
325
- 1200
326
- );
327
- pings[HomeyAPI.DISCOVERY_STRATEGIES.LOCAL_SECURE].catch((err) => {
328
- this.__debug(
329
- `Ping ${HomeyAPI.DISCOVERY_STRATEGIES.LOCAL_SECURE} Error:`,
330
- err && err.message
331
- );
332
- this.__debug(urls[HomeyAPI.DISCOVERY_STRATEGIES.LOCAL_SECURE]);
333
- });
334
- }
335
-
336
- // Ping local (http://xxx-xxx-xxx-xxx)
337
- if (urls[HomeyAPI.DISCOVERY_STRATEGIES.LOCAL]) {
338
- pings[HomeyAPI.DISCOVERY_STRATEGIES.LOCAL] = ping(HomeyAPI.DISCOVERY_STRATEGIES.LOCAL, 1000);
339
- pings[HomeyAPI.DISCOVERY_STRATEGIES.LOCAL].catch((err) =>
340
- this.__debug(`Ping ${HomeyAPI.DISCOVERY_STRATEGIES.LOCAL} Error:`, err && err.message)
341
- );
342
- }
343
-
344
- // Ping mdns (http://homey-<homeyId>.local)
345
- if (urls[HomeyAPI.DISCOVERY_STRATEGIES.MDNS]) {
346
- pings[HomeyAPI.DISCOVERY_STRATEGIES.MDNS] = ping(HomeyAPI.DISCOVERY_STRATEGIES.MDNS, 3000);
347
- pings[HomeyAPI.DISCOVERY_STRATEGIES.MDNS].catch((err) =>
348
- this.__debug(`Ping ${HomeyAPI.DISCOVERY_STRATEGIES.MDNS} Error:`, err && err.message)
349
- );
350
- }
351
-
352
- // Ping cloud (https://<homeyId>.connect.athom.com)
353
- if (urls[HomeyAPI.DISCOVERY_STRATEGIES.CLOUD]) {
354
- pings[HomeyAPI.DISCOVERY_STRATEGIES.CLOUD] = ping(HomeyAPI.DISCOVERY_STRATEGIES.CLOUD, 5000);
355
- pings[HomeyAPI.DISCOVERY_STRATEGIES.CLOUD].catch((err) =>
356
- this.__debug(`Ping ${HomeyAPI.DISCOVERY_STRATEGIES.CLOUD} Error:`, err && err.message)
357
- );
358
- }
359
-
360
- // Ping Direct (https://xxx-xxx-xxx-xx.homey.homeylocal.com:12345)
361
- if (urls[HomeyAPI.DISCOVERY_STRATEGIES.REMOTE_FORWARDED]) {
362
- pings[HomeyAPI.DISCOVERY_STRATEGIES.REMOTE_FORWARDED] = ping(
363
- HomeyAPI.DISCOVERY_STRATEGIES.REMOTE_FORWARDED,
364
- 2000
365
- );
366
- pings[HomeyAPI.DISCOVERY_STRATEGIES.REMOTE_FORWARDED].catch((err) =>
367
- this.__debug(
368
- `Ping ${HomeyAPI.DISCOVERY_STRATEGIES.REMOTE_FORWARDED} Error:`,
369
- err && err.message
370
- )
371
- );
372
- }
373
-
374
- // Select the best route
375
- if (pings[HomeyAPI.DISCOVERY_STRATEGIES.LOCAL_SECURE]) {
376
- pings[HomeyAPI.DISCOVERY_STRATEGIES.LOCAL_SECURE]
377
- .then((result) => resolve(result))
378
- .catch((error) => {
379
- const promises = [];
380
-
381
- if (pings[HomeyAPI.DISCOVERY_STRATEGIES.LOCAL]) {
382
- promises.push(pings[HomeyAPI.DISCOVERY_STRATEGIES.LOCAL]);
383
- }
384
-
385
- if (pings[HomeyAPI.DISCOVERY_STRATEGIES.REMOTE_FORWARDED]) {
386
- promises.push(pings[HomeyAPI.DISCOVERY_STRATEGIES.REMOTE_FORWARDED]);
387
- }
388
-
389
- if (pings[HomeyAPI.DISCOVERY_STRATEGIES.MDNS]) {
390
- promises.push(pings[HomeyAPI.DISCOVERY_STRATEGIES.MDNS]);
391
- }
392
-
393
- if (pings[HomeyAPI.DISCOVERY_STRATEGIES.CLOUD]) {
394
- promises.push(pings[HomeyAPI.DISCOVERY_STRATEGIES.CLOUD]);
395
- }
396
-
397
- if (
398
- error instanceof APIErrorHomeySubscriptionInactive ||
399
- error instanceof APIErrorHomeyInvalidSerialNumber
400
- ) {
401
- throw error;
402
- }
403
-
404
- if (!promises.length) {
405
- throw new APIErrorHomeyOffline();
406
- }
407
-
408
- return Promise.any(promises);
409
- })
410
- .then((result) => resolve(result))
411
- .catch((error) => {
412
- if (error instanceof AggregateError) {
413
- for (const err of error.errors) {
414
- if (
415
- err instanceof APIErrorHomeySubscriptionInactive ||
416
- err instanceof APIErrorHomeyInvalidSerialNumber
417
- ) {
418
- reject(err);
419
- return;
420
- }
421
- }
422
- }
423
-
424
- if (
425
- error instanceof APIErrorHomeySubscriptionInactive ||
426
- error instanceof APIErrorHomeyInvalidSerialNumber
427
- ) {
428
- reject(error);
429
- return;
430
- }
431
-
432
- reject(new APIErrorHomeyOffline());
433
- });
434
- } else if (pings[HomeyAPI.DISCOVERY_STRATEGIES.LOCAL]) {
435
- pings[HomeyAPI.DISCOVERY_STRATEGIES.LOCAL]
436
- .then((result) => resolve(result))
437
- .catch((err) => {
438
- if (pings[HomeyAPI.DISCOVERY_STRATEGIES.CLOUD]) {
439
- pings[HomeyAPI.DISCOVERY_STRATEGIES.CLOUD]
440
- .then((result) => resolve(result))
441
- .catch((err) => {
442
- if (
443
- err instanceof APIErrorHomeySubscriptionInactive ||
444
- err instanceof APIErrorHomeyInvalidSerialNumber
445
- ) {
446
- reject(err);
447
- return;
448
- }
449
-
450
- reject(new APIErrorHomeyOffline(err));
451
- });
452
- } else {
453
- reject(new APIErrorHomeyOffline(err));
454
- }
455
- });
456
- } else if (pings[HomeyAPI.DISCOVERY_STRATEGIES.MDNS]) {
457
- pings[HomeyAPI.DISCOVERY_STRATEGIES.MDNS]
458
- .then((result) => resolve(result))
459
- .catch((err) => {
460
- if (pings[HomeyAPI.DISCOVERY_STRATEGIES.CLOUD]) {
461
- pings[HomeyAPI.DISCOVERY_STRATEGIES.CLOUD]
462
- .then((result) => resolve(result))
463
- .catch((err) => {
464
- if (
465
- err instanceof APIErrorHomeySubscriptionInactive ||
466
- err instanceof APIErrorHomeyInvalidSerialNumber
467
- ) {
468
- reject(err);
469
- return;
470
- }
471
-
472
- reject(new APIErrorHomeyOffline(err));
473
- });
474
- } else {
475
- reject(new APIErrorHomeyOffline(err));
476
- }
477
- });
478
- } else if (pings[HomeyAPI.DISCOVERY_STRATEGIES.REMOTE_FORWARDED]) {
479
- pings[HomeyAPI.DISCOVERY_STRATEGIES.REMOTE_FORWARDED]
480
- .then((result) => resolve(result))
481
- .catch((err) => {
482
- if (pings[HomeyAPI.DISCOVERY_STRATEGIES.CLOUD]) {
483
- pings[HomeyAPI.DISCOVERY_STRATEGIES.CLOUD]
484
- .then((result) => resolve(result))
485
- .catch((err) => {
486
- if (
487
- err instanceof APIErrorHomeySubscriptionInactive ||
488
- err instanceof APIErrorHomeyInvalidSerialNumber
489
- ) {
490
- reject(err);
491
- return;
492
- }
493
-
494
- reject(new APIErrorHomeyOffline(err));
495
- });
496
- } else {
497
- reject(new APIErrorHomeyOffline(err));
498
- }
499
- });
500
- } else if (pings[HomeyAPI.DISCOVERY_STRATEGIES.CLOUD]) {
501
- pings[HomeyAPI.DISCOVERY_STRATEGIES.CLOUD]
502
- .then((result) => resolve(result))
503
- .catch((err) => {
504
- if (
505
- err instanceof APIErrorHomeySubscriptionInactive ||
506
- err instanceof APIErrorHomeyInvalidSerialNumber
507
- ) {
508
- reject(err);
509
- return;
510
- }
511
-
512
- reject(new APIErrorHomeyOffline(err));
513
- });
514
- } else {
515
- reject(new APIErrorHomeyOffline());
516
- }
517
-
518
- return promise;
519
214
  }
520
215
 
521
216
  async call({
package/lib/Util.js CHANGED
@@ -2,6 +2,244 @@
2
2
 
3
3
  const APIErrorTimeout = require('./APIErrorTimeout');
4
4
 
5
+ let httpAgent = null;
6
+ let httpsAgent = null;
7
+
8
+ function getNodeHttpModules() {
9
+ return {
10
+ http: require('node:http'),
11
+ https: require('node:https'),
12
+ };
13
+ }
14
+
15
+ function getDnsModule() {
16
+ return require('node:dns');
17
+ }
18
+
19
+ function getNetModule() {
20
+ return require('node:net');
21
+ }
22
+
23
+ function getSharedNodeFetchAgents() {
24
+ const { http, https } = getNodeHttpModules();
25
+
26
+ httpAgent = httpAgent || new http.Agent({
27
+ keepAlive: false,
28
+ });
29
+ httpsAgent = httpsAgent || new https.Agent({
30
+ keepAlive: false,
31
+ });
32
+
33
+ return {
34
+ httpAgent,
35
+ httpsAgent,
36
+ };
37
+ }
38
+
39
+ function getNodeFetchAgent(url) {
40
+ const { httpAgent, httpsAgent } = getSharedNodeFetchAgents();
41
+
42
+ if (typeof url !== 'string') {
43
+ return undefined;
44
+ }
45
+
46
+ if (url.startsWith('https://')) {
47
+ return httpsAgent;
48
+ }
49
+
50
+ if (url.startsWith('http://')) {
51
+ return httpAgent;
52
+ }
53
+
54
+ return undefined;
55
+ }
56
+
57
+ function createAbortError(reason) {
58
+ const error = new Error(
59
+ typeof reason === 'string' && reason.length > 0
60
+ ? reason
61
+ : 'The operation was aborted',
62
+ );
63
+ error.name = 'AbortError';
64
+ error.code = 'ABORT_ERR';
65
+ error.type = 'aborted';
66
+ return error;
67
+ }
68
+
69
+ function shouldUseCancellableResolverLookup(hostname) {
70
+ if (typeof hostname !== 'string') {
71
+ return false;
72
+ }
73
+
74
+ return hostname.endsWith('.homey.homeylocal.com');
75
+ }
76
+
77
+ function resolveHostnameWithResolver({ dns, hostname, options, finish }) {
78
+ const resolver = new dns.Resolver();
79
+ const family = Number(options?.family) || 0;
80
+ const wantsAll = options?.all === true;
81
+
82
+ const resolve4 = (callback) => resolver.resolve4(hostname, callback);
83
+ const resolve6 = (callback) => resolver.resolve6(hostname, callback);
84
+
85
+ const finishWithAddresses = (addresses, addressFamily) => {
86
+ if (!Array.isArray(addresses) || addresses.length === 0) {
87
+ finish(new Error(`No DNS results for ${hostname}`));
88
+ return;
89
+ }
90
+
91
+ if (wantsAll) {
92
+ finish(
93
+ null,
94
+ addresses.map((address) => ({
95
+ address,
96
+ family: addressFamily,
97
+ })),
98
+ );
99
+ return;
100
+ }
101
+
102
+ finish(null, addresses[0], addressFamily);
103
+ };
104
+
105
+ const maybeResolve6After4 = (error) => {
106
+ if (family === 4) {
107
+ finish(error);
108
+ return;
109
+ }
110
+
111
+ resolve6((resolve6Error, addresses) => {
112
+ if (resolve6Error) {
113
+ finish(error || resolve6Error);
114
+ return;
115
+ }
116
+
117
+ finishWithAddresses(addresses, 6);
118
+ });
119
+ };
120
+
121
+ if (family === 6) {
122
+ resolve6((error, addresses) => {
123
+ if (error) {
124
+ finish(error);
125
+ return;
126
+ }
127
+
128
+ finishWithAddresses(addresses, 6);
129
+ });
130
+
131
+ return () => resolver.cancel();
132
+ }
133
+
134
+ resolve4((error, addresses) => {
135
+ if (error) {
136
+ maybeResolve6After4(error);
137
+ return;
138
+ }
139
+
140
+ finishWithAddresses(addresses, 4);
141
+ });
142
+
143
+ return () => resolver.cancel();
144
+ }
145
+
146
+ function createTimedLookup(timeoutDuration, signal) {
147
+ const dns = getDnsModule();
148
+ const net = getNetModule();
149
+
150
+ return (hostname, options, callback) => {
151
+ let settled = false;
152
+ let timer = null;
153
+ let handleAbort = null;
154
+ let cancelLookup = null;
155
+
156
+ const finish = (error, address, family) => {
157
+ if (settled) {
158
+ return;
159
+ }
160
+
161
+ settled = true;
162
+ clearTimeout(timer);
163
+
164
+ if (signal && handleAbort) {
165
+ signal.removeEventListener('abort', handleAbort);
166
+ }
167
+
168
+ if (cancelLookup) {
169
+ cancelLookup();
170
+ cancelLookup = null;
171
+ }
172
+
173
+ callback(error, address, family);
174
+ };
175
+
176
+ const ipFamily = net.isIP(hostname);
177
+
178
+ if (ipFamily) {
179
+ finish(null, hostname, ipFamily);
180
+ return;
181
+ }
182
+
183
+ if (signal) {
184
+ if (signal.aborted) {
185
+ finish(createAbortError(signal.reason));
186
+ return;
187
+ }
188
+
189
+ handleAbort = () => {
190
+ finish(createAbortError(signal.reason));
191
+ };
192
+
193
+ signal.addEventListener('abort', handleAbort, { once: true });
194
+ }
195
+
196
+ timer = setTimeout(() => {
197
+ const error = new APIErrorTimeout(`DNS lookup timeout after ${timeoutDuration}ms`);
198
+ error.code = 'ETIMEDOUT';
199
+ error.syscall = 'getaddrinfo';
200
+ error.stage = 'dns-lookup';
201
+ finish(error);
202
+ }, timeoutDuration);
203
+
204
+ if (shouldUseCancellableResolverLookup(hostname)) {
205
+ cancelLookup = resolveHostnameWithResolver({
206
+ dns,
207
+ hostname,
208
+ options,
209
+ finish,
210
+ });
211
+ return;
212
+ }
213
+
214
+ dns.lookup(hostname, options, finish);
215
+ };
216
+ }
217
+
218
+ function getTimedNodeFetchAgent(url, timeoutDuration, signal) {
219
+ const { http, https } = getNodeHttpModules();
220
+ const lookup = createTimedLookup(timeoutDuration, signal);
221
+
222
+ if (typeof url !== 'string') {
223
+ return undefined;
224
+ }
225
+
226
+ if (url.startsWith('https://')) {
227
+ return new https.Agent({
228
+ keepAlive: false,
229
+ lookup,
230
+ });
231
+ }
232
+
233
+ if (url.startsWith('http://')) {
234
+ return new http.Agent({
235
+ keepAlive: false,
236
+ lookup,
237
+ });
238
+ }
239
+
240
+ return undefined;
241
+ }
242
+
5
243
  /**
6
244
  * Helper Utility Class
7
245
  * @class
@@ -19,16 +257,41 @@ class Util {
19
257
  */
20
258
  static async fetch(url, options, timeoutDuration, timeoutMessage) {
21
259
  options = { ...options };
22
- let abortTimeout = null;
260
+ let timeoutTimer = null;
261
+ let composedAbortController = null;
262
+ let didTimeoutTriggerAbort = false;
263
+ let externalSignal = null;
264
+ let handleExternalAbort = null;
23
265
 
24
266
  if (timeoutDuration != null) {
25
- const abortController = new AbortController();
267
+ composedAbortController = new AbortController();
268
+ externalSignal = options.signal;
269
+
270
+ if (externalSignal) {
271
+ if (externalSignal.aborted) {
272
+ composedAbortController.abort(externalSignal.reason);
273
+ } else {
274
+ handleExternalAbort = () => {
275
+ composedAbortController.abort(externalSignal.reason);
276
+ };
277
+ externalSignal.addEventListener(
278
+ 'abort',
279
+ handleExternalAbort,
280
+ { once: true }
281
+ );
282
+ }
283
+ }
26
284
 
27
- abortTimeout = setTimeout(() => {
28
- abortController.abort('Timeout');
285
+ timeoutTimer = setTimeout(() => {
286
+ if (composedAbortController.signal.aborted) {
287
+ return;
288
+ }
289
+
290
+ didTimeoutTriggerAbort = true;
291
+ composedAbortController.abort('Timeout');
29
292
  }, timeoutDuration);
30
293
 
31
- options.signal = abortController.signal;
294
+ options.signal = composedAbortController.signal;
32
295
  }
33
296
 
34
297
  let responsePromise = null;
@@ -39,6 +302,13 @@ class Util {
39
302
  responsePromise = window.fetch(url, options);
40
303
  } else if (this.isNodeJS()) {
41
304
  const fetch = require('node-fetch');
305
+
306
+ if (typeof options.agent === 'undefined') {
307
+ options.agent = timeoutDuration != null
308
+ ? getTimedNodeFetchAgent(url, timeoutDuration, options.signal)
309
+ : getNodeFetchAgent(url);
310
+ }
311
+
42
312
  responsePromise = fetch(url, options);
43
313
  } else if (typeof fetch !== 'undefined') {
44
314
  responsePromise = fetch(url, options);
@@ -51,20 +321,16 @@ class Util {
51
321
 
52
322
  return result;
53
323
  } catch (err) {
54
- if (err.name === 'AbortError') {
55
- if (options.signal && options.signal.aborted && options.signal.reason === 'Timeout') {
56
- throw new APIErrorTimeout(timeoutMessage ?? `Timeout after ${timeoutDuration}ms`);
57
- }
58
- }
59
-
60
- if (err.type === 'aborted') {
61
- // https://github.com/node-fetch/node-fetch/blob/2.x/src/abort-error.js
324
+ if ((err.name === 'AbortError' || err.type === 'aborted') && didTimeoutTriggerAbort) {
62
325
  throw new APIErrorTimeout(timeoutMessage ?? `Timeout after ${timeoutDuration}ms`);
63
326
  }
64
327
 
65
328
  throw err;
66
329
  } finally {
67
- clearTimeout(abortTimeout);
330
+ clearTimeout(timeoutTimer);
331
+ if (externalSignal && handleExternalAbort) {
332
+ externalSignal.removeEventListener('abort', handleExternalAbort);
333
+ }
68
334
  }
69
335
  }
70
336
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homey-api",
3
- "version": "3.17.0",
3
+ "version": "3.17.2",
4
4
  "description": "Homey API",
5
5
  "main": "index.js",
6
6
  "license": "SEE LICENSE",
@@ -13,7 +13,7 @@
13
13
  ],
14
14
  "types": "assets/types/homey-api.d.ts",
15
15
  "scripts": {
16
- "test": "jest --verbose",
16
+ "test": "node --test",
17
17
  "lint": "eslint .",
18
18
  "serve": "concurrently \"serve jsdoc/\" \"npm run jsdoc:watch\"",
19
19
  "build": "npm run build:specs && npm run build:jsdoc && npm run build:types && npm run build:webpack;",
@@ -46,7 +46,7 @@
46
46
  },
47
47
  "homepage": "https://github.com/athombv/node-homey-api#readme",
48
48
  "engines": {
49
- "node": ">=16"
49
+ "node": ">=22"
50
50
  },
51
51
  "dependencies": {
52
52
  "engine.io-client": "^3.5.5",
@@ -59,7 +59,6 @@
59
59
  "@babel/core": "^7.16.0",
60
60
  "@babel/plugin-proposal-class-properties": "^7.16.0",
61
61
  "@babel/preset-env": "^7.16.0",
62
- "@types/jest": "^29.5.12",
63
62
  "@typescript-eslint/eslint-plugin": "^6.1.0",
64
63
  "@typescript-eslint/parser": "^6.1.0",
65
64
  "babel-loader": "^8.2.3",
@@ -67,12 +66,10 @@
67
66
  "ejs": "^3.1.6",
68
67
  "eslint": "^7.32.0",
69
68
  "eslint-config-athom": "^2.1.1",
70
- "eslint-plugin-jest": "^27.4.0",
71
69
  "express": "^4.18.2",
72
70
  "fs-extra": "^10.0.0",
73
71
  "homey-api": "^3.0.8",
74
72
  "http-server": "^0.12.3",
75
- "jest": "^30.0.0-alpha.2",
76
73
  "jsdoc": "4.0.5",
77
74
  "jsdoc-to-markdown": "^8.0.0",
78
75
  "jsdoc-ts-utils": "^4.0.0",