homey-api 3.16.1 → 3.17.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.
@@ -5782,12 +5782,6 @@ export class Util {
5782
5782
 
5783
5783
  static encodeUrlSearchParams(params: object): string;
5784
5784
 
5785
- static deepEqual(
5786
- a: any,
5787
-
5788
- b: any,
5789
- ): boolean;
5790
-
5791
5785
  static fetch(
5792
5786
  args: string,
5793
5787
 
@@ -5829,12 +5823,6 @@ export class Util {
5829
5823
  static serializeQueryObject(queryObject: object): string;
5830
5824
 
5831
5825
  static encodeUrlSearchParams(params: object): string;
5832
-
5833
- static deepEqual(
5834
- a: any,
5835
-
5836
- b: any,
5837
- ): boolean;
5838
5826
  }
5839
5827
 
5840
5828
  export class APIDefinition {}
@@ -9212,12 +9200,6 @@ export class Util {
9212
9200
 
9213
9201
  static encodeUrlSearchParams(params: object): string;
9214
9202
 
9215
- static deepEqual(
9216
- a: any,
9217
-
9218
- b: any,
9219
- ): boolean;
9220
-
9221
9203
  static fetch(
9222
9204
  args: string,
9223
9205
 
@@ -9259,12 +9241,6 @@ export class Util {
9259
9241
  static serializeQueryObject(queryObject: object): string;
9260
9242
 
9261
9243
  static encodeUrlSearchParams(params: object): string;
9262
-
9263
- static deepEqual(
9264
- a: any,
9265
-
9266
- b: any,
9267
- ): boolean;
9268
9244
  }
9269
9245
 
9270
9246
  export namespace HomeyAPIV3Cloud {
@@ -0,0 +1,290 @@
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
+ urls[DISCOVERY_STRATEGIES.MDNS] = `http://homey-${this.homey.id}.local`;
20
+ }
21
+ }
22
+
23
+ if (this.homey.__strategies.includes(DISCOVERY_STRATEGIES.LOCAL)) {
24
+ if (Util.isHTTPUnsecureSupported() && this.homey.__properties.localUrl) {
25
+ urls[DISCOVERY_STRATEGIES.LOCAL] = `${this.homey.__properties.localUrl}`;
26
+ }
27
+ }
28
+
29
+ if (this.homey.__strategies.includes(DISCOVERY_STRATEGIES.LOCAL_SECURE)) {
30
+ if (this.homey.__properties.localUrlSecure) {
31
+ urls[DISCOVERY_STRATEGIES.LOCAL_SECURE] = `${this.homey.__properties.localUrlSecure}`;
32
+ }
33
+ }
34
+
35
+ if (this.homey.__strategies.includes(DISCOVERY_STRATEGIES.CLOUD)) {
36
+ if (this.homey.__properties.remoteUrl) {
37
+ urls[DISCOVERY_STRATEGIES.CLOUD] = `${this.homey.__properties.remoteUrl}`;
38
+ }
39
+ }
40
+
41
+ if (this.homey.__strategies.includes(DISCOVERY_STRATEGIES.REMOTE_FORWARDED)) {
42
+ if (this.homey.__properties.remoteUrlForwarded) {
43
+ urls[DISCOVERY_STRATEGIES.REMOTE_FORWARDED] =
44
+ `${this.homey.__properties.remoteUrlForwarded}`;
45
+ }
46
+ }
47
+
48
+ if (!Object.keys(urls).length) {
49
+ throw new Error('No Discovery Strategies Available');
50
+ }
51
+
52
+ this.homey.__debug(`Discovery Strategies: ${Object.keys(urls).join(',')}`);
53
+
54
+ let resolve;
55
+ let reject;
56
+
57
+ const promise = new Promise((resolve_, reject_) => {
58
+ resolve = resolve_;
59
+ reject = reject_;
60
+ });
61
+
62
+ const pingAbortControllers = {};
63
+
64
+ const abortPendingPings = ({ exceptStrategyId } = {}) => {
65
+ Object.entries(pingAbortControllers).forEach(([strategyId, abortController]) => {
66
+ if (strategyId === exceptStrategyId) return;
67
+ if (abortController.signal.aborted) return;
68
+ abortController.abort('Discovery Completed');
69
+ });
70
+ };
71
+
72
+ const resolveAndAbortPendingPings = (result) => {
73
+ abortPendingPings({ exceptStrategyId: result.strategyId });
74
+ resolve(result);
75
+ return result;
76
+ };
77
+
78
+ const rejectAndAbortPendingPings = (error) => {
79
+ abortPendingPings();
80
+ reject(error);
81
+ };
82
+
83
+ const isSubscriptionError = (error) => {
84
+ return (
85
+ error instanceof APIErrorHomeySubscriptionInactive ||
86
+ error instanceof APIErrorHomeyInvalidSerialNumber
87
+ );
88
+ };
89
+
90
+ const normalizeDiscoveryError = (error) => {
91
+ if (error instanceof AggregateError) {
92
+ for (const err of error.errors) {
93
+ if (isSubscriptionError(err)) {
94
+ return err;
95
+ }
96
+ }
97
+
98
+ return new APIErrorHomeyOffline();
99
+ }
100
+
101
+ if (isSubscriptionError(error) || error instanceof APIErrorHomeyOffline) {
102
+ return error;
103
+ }
104
+
105
+ return new APIErrorHomeyOffline(error);
106
+ };
107
+
108
+ const ping = async (strategyId, timeout) => {
109
+ const baseUrl = urls[strategyId];
110
+ const abortController = new AbortController();
111
+ pingAbortControllers[strategyId] = abortController;
112
+
113
+ const response = await Util.fetch(
114
+ `${baseUrl}/api/manager/system/ping?id=${this.homey.id}`,
115
+ {
116
+ headers: {
117
+ 'X-Homey-ID': this.homey.id,
118
+ },
119
+ signal: abortController.signal,
120
+ },
121
+ timeout
122
+ );
123
+
124
+ const text = await response.text();
125
+
126
+ if (!response.ok) {
127
+ const responseContentType = response.headers.get('Content-Type');
128
+ let parsed;
129
+
130
+ if (responseContentType && responseContentType.toLowerCase().includes('application/json')) {
131
+ try {
132
+ parsed = JSON.parse(text);
133
+ // eslint-disable-next-line no-empty
134
+ } catch (err) {}
135
+ }
136
+
137
+ if (typeof parsed === 'object' && parsed !== null) {
138
+ if (
139
+ parsed.error &&
140
+ parsed.error === 'Subscription Inactive' &&
141
+ parsed.statusCode === 403
142
+ ) {
143
+ throw new APIErrorHomeySubscriptionInactive(parsed.error, parsed.statusCode);
144
+ } else if (
145
+ parsed.error &&
146
+ parsed.error === 'Invalid Serial Number' &&
147
+ parsed.statusCode === 403
148
+ ) {
149
+ throw new APIErrorHomeyInvalidSerialNumber(parsed.error, parsed.statusCode);
150
+ }
151
+ }
152
+
153
+ throw new Error(text || response.statusText);
154
+ }
155
+
156
+ if (text === 'false') {
157
+ throw new Error('Invalid Homey ID');
158
+ }
159
+
160
+ const homeyId = response.headers.get('X-Homey-ID');
161
+
162
+ if (homeyId && homeyId !== this.homey.id) {
163
+ throw new Error('Invalid Homey ID');
164
+ }
165
+
166
+ const homeyVersion = response.headers.get('X-Homey-Version');
167
+
168
+ return {
169
+ baseUrl,
170
+ strategyId,
171
+ homeyVersion,
172
+ };
173
+ };
174
+
175
+ const pings = {};
176
+
177
+ if (urls[DISCOVERY_STRATEGIES.LOCAL_SECURE]) {
178
+ pings[DISCOVERY_STRATEGIES.LOCAL_SECURE] = ping(
179
+ DISCOVERY_STRATEGIES.LOCAL_SECURE,
180
+ 1200
181
+ );
182
+ pings[DISCOVERY_STRATEGIES.LOCAL_SECURE].catch((err) => {
183
+ this.homey.__debug(
184
+ `Ping ${DISCOVERY_STRATEGIES.LOCAL_SECURE} Error:`,
185
+ err && err.message
186
+ );
187
+ this.homey.__debug(urls[DISCOVERY_STRATEGIES.LOCAL_SECURE]);
188
+ });
189
+ }
190
+
191
+ if (urls[DISCOVERY_STRATEGIES.LOCAL]) {
192
+ pings[DISCOVERY_STRATEGIES.LOCAL] = ping(DISCOVERY_STRATEGIES.LOCAL, 1000);
193
+ pings[DISCOVERY_STRATEGIES.LOCAL].catch((err) =>
194
+ this.homey.__debug(`Ping ${DISCOVERY_STRATEGIES.LOCAL} Error:`, err && err.message)
195
+ );
196
+ }
197
+
198
+ if (urls[DISCOVERY_STRATEGIES.MDNS]) {
199
+ pings[DISCOVERY_STRATEGIES.MDNS] = ping(DISCOVERY_STRATEGIES.MDNS, 3000);
200
+ pings[DISCOVERY_STRATEGIES.MDNS].catch((err) =>
201
+ this.homey.__debug(`Ping ${DISCOVERY_STRATEGIES.MDNS} Error:`, err && err.message)
202
+ );
203
+ }
204
+
205
+ if (urls[DISCOVERY_STRATEGIES.CLOUD]) {
206
+ pings[DISCOVERY_STRATEGIES.CLOUD] = ping(DISCOVERY_STRATEGIES.CLOUD, 5000);
207
+ pings[DISCOVERY_STRATEGIES.CLOUD].catch((err) =>
208
+ this.homey.__debug(`Ping ${DISCOVERY_STRATEGIES.CLOUD} Error:`, err && err.message)
209
+ );
210
+ }
211
+
212
+ if (urls[DISCOVERY_STRATEGIES.REMOTE_FORWARDED]) {
213
+ pings[DISCOVERY_STRATEGIES.REMOTE_FORWARDED] = ping(
214
+ DISCOVERY_STRATEGIES.REMOTE_FORWARDED,
215
+ 2000
216
+ );
217
+ pings[DISCOVERY_STRATEGIES.REMOTE_FORWARDED].catch((err) =>
218
+ this.homey.__debug(
219
+ `Ping ${DISCOVERY_STRATEGIES.REMOTE_FORWARDED} Error:`,
220
+ err && err.message
221
+ )
222
+ );
223
+ }
224
+
225
+ const withCloudFallback = (primaryPromise) => {
226
+ return primaryPromise.catch((error) => {
227
+ if (pings[DISCOVERY_STRATEGIES.CLOUD]) {
228
+ return pings[DISCOVERY_STRATEGIES.CLOUD];
229
+ }
230
+
231
+ throw new APIErrorHomeyOffline(error);
232
+ });
233
+ };
234
+
235
+ let selectedRoutePromise;
236
+
237
+ if (pings[DISCOVERY_STRATEGIES.LOCAL_SECURE]) {
238
+ selectedRoutePromise = pings[DISCOVERY_STRATEGIES.LOCAL_SECURE]
239
+ .catch((error) => {
240
+ const fallbackPromises = [];
241
+
242
+ if (pings[DISCOVERY_STRATEGIES.LOCAL]) {
243
+ fallbackPromises.push(pings[DISCOVERY_STRATEGIES.LOCAL]);
244
+ }
245
+
246
+ if (pings[DISCOVERY_STRATEGIES.REMOTE_FORWARDED]) {
247
+ fallbackPromises.push(pings[DISCOVERY_STRATEGIES.REMOTE_FORWARDED]);
248
+ }
249
+
250
+ if (pings[DISCOVERY_STRATEGIES.MDNS]) {
251
+ fallbackPromises.push(pings[DISCOVERY_STRATEGIES.MDNS]);
252
+ }
253
+
254
+ if (pings[DISCOVERY_STRATEGIES.CLOUD]) {
255
+ fallbackPromises.push(pings[DISCOVERY_STRATEGIES.CLOUD]);
256
+ }
257
+
258
+ if (isSubscriptionError(error)) {
259
+ throw error;
260
+ }
261
+
262
+ if (!fallbackPromises.length) {
263
+ throw new APIErrorHomeyOffline();
264
+ }
265
+
266
+ return Promise.any(fallbackPromises);
267
+ });
268
+ } else if (pings[DISCOVERY_STRATEGIES.LOCAL]) {
269
+ selectedRoutePromise = withCloudFallback(pings[DISCOVERY_STRATEGIES.LOCAL]);
270
+ } else if (pings[DISCOVERY_STRATEGIES.MDNS]) {
271
+ selectedRoutePromise = withCloudFallback(pings[DISCOVERY_STRATEGIES.MDNS]);
272
+ } else if (pings[DISCOVERY_STRATEGIES.REMOTE_FORWARDED]) {
273
+ selectedRoutePromise = withCloudFallback(pings[DISCOVERY_STRATEGIES.REMOTE_FORWARDED]);
274
+ } else if (pings[DISCOVERY_STRATEGIES.CLOUD]) {
275
+ selectedRoutePromise = pings[DISCOVERY_STRATEGIES.CLOUD];
276
+ } else {
277
+ selectedRoutePromise = Promise.reject(new APIErrorHomeyOffline());
278
+ }
279
+
280
+ selectedRoutePromise
281
+ .then((result) => resolveAndAbortPendingPings(result))
282
+ .catch((error) => {
283
+ rejectAndAbortPendingPings(normalizeDiscoveryError(error));
284
+ });
285
+
286
+ return promise;
287
+ }
288
+ }
289
+
290
+ module.exports = DiscoveryManager;
@@ -1,7 +1,6 @@
1
1
  'use strict';
2
2
 
3
3
  const EventEmitter = require('../../EventEmitter');
4
- const Util = require('../../Util');
5
4
 
6
5
  /**
7
6
  * A superclass for all CRUD Items.
@@ -95,43 +94,14 @@ class Item extends EventEmitter {
95
94
  }
96
95
 
97
96
  __update(properties) {
98
- const oldValues = {};
99
- const newValues = {};
100
- const changedKeys = [];
101
-
102
97
  for (const [key, value] of Object.entries(properties)) {
103
98
  if (key === 'id') continue;
104
-
105
- if (!Util.deepEqual(this[key], value)) {
106
- oldValues[key] = Util.deepClone(this[key]);
107
- newValues[key] = Util.deepClone(value);
108
- changedKeys.push(key);
109
-
110
- this[key] = value;
111
- }
99
+ this[key] = value;
112
100
  }
113
101
 
114
102
  this.__lastUpdated = new Date();
115
103
 
116
- if (changedKeys.length === 0) {
117
- return {
118
- oldValues,
119
- newValues,
120
- changedKeys,
121
- };
122
- }
123
-
124
- this.emit('update', properties, {
125
- oldValues,
126
- newValues,
127
- changedKeys,
128
- });
129
-
130
- return {
131
- oldValues,
132
- newValues,
133
- changedKeys,
134
- };
104
+ this.emit('update', properties);
135
105
  }
136
106
 
137
107
  __delete() {
@@ -531,19 +531,8 @@ class Manager extends EventEmitter {
531
531
 
532
532
  if (this.__cache[ItemClass.ID][props.id]) {
533
533
  const item = this.__cache[ItemClass.ID][props.id];
534
- const {
535
- oldValues,
536
- newValues,
537
- changedKeys,
538
- } = item.__update(props);
539
-
540
- if (changedKeys.length === 0) return;
541
-
542
- return this.emit(event, item, {
543
- oldValues,
544
- newValues,
545
- changedKeys,
546
- });
534
+ item.__update(props);
535
+ return this.emit(event, item);
547
536
  }
548
537
 
549
538
  break;
@@ -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
+ }
284
+
285
+ timeoutTimer = setTimeout(() => {
286
+ if (composedAbortController.signal.aborted) {
287
+ return;
288
+ }
26
289
 
27
- abortTimeout = setTimeout(() => {
28
- abortController.abort('Timeout');
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
 
@@ -338,92 +604,6 @@ class Util {
338
604
  return encodedPairs.join('&');
339
605
  }
340
606
 
341
- /**
342
- * Deep equality check between two values.
343
- * @param {any} a
344
- * @param {any} b
345
- * @returns {boolean}
346
- */
347
- static deepEqual(a, b) {
348
- if (a === b) return true;
349
-
350
- if (
351
- typeof a !== 'object' ||
352
- typeof b !== 'object' ||
353
- a === null ||
354
- b === null
355
- ) {
356
- return false;
357
- }
358
-
359
- const keysA = Object.keys(a);
360
- const keysB = Object.keys(b);
361
-
362
- if (keysA.length !== keysB.length) return false;
363
-
364
- for (const key of keysA) {
365
- if (!keysB.includes(key) || !this.deepEqual(a[key], b[key])) {
366
- return false;
367
- }
368
- }
369
-
370
- return true;
371
- }
372
-
373
- static deepClone(value) {
374
- // Use native structuredClone when available
375
- if (typeof structuredClone === 'function') {
376
- return structuredClone(value);
377
- }
378
-
379
- const seen = new WeakMap();
380
-
381
- function clone(val) {
382
- if (val === null || typeof val !== 'object') {
383
- return val;
384
- }
385
-
386
- if (seen.has(val)) {
387
- return seen.get(val);
388
- }
389
-
390
- if (val instanceof Date) {
391
- return new Date(val);
392
- }
393
-
394
- if (val instanceof Map) {
395
- const map = new Map();
396
- seen.set(val, map);
397
- val.forEach((v, k) => map.set(clone(k), clone(v)));
398
- return map;
399
- }
400
-
401
- if (val instanceof Set) {
402
- const set = new Set();
403
- seen.set(val, set);
404
- val.forEach(v => set.add(clone(v)));
405
- return set;
406
- }
407
-
408
- if (Array.isArray(val)) {
409
- const arr = [];
410
- seen.set(val, arr);
411
- val.forEach((v, i) => arr[i] = clone(v));
412
- return arr;
413
- }
414
-
415
- const obj = {};
416
- seen.set(val, obj);
417
- Object.keys(val).forEach(key => {
418
- obj[key] = clone(val[key]);
419
- });
420
-
421
- return obj;
422
- }
423
-
424
- return clone(value);
425
- }
426
-
427
607
  }
428
608
 
429
609
  module.exports = Util;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homey-api",
3
- "version": "3.16.1",
3
+ "version": "3.17.1",
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",