yoto-nodejs-client 0.0.6 → 0.0.7
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/README.md +51 -36
- package/lib/test-helpers/device-model-test-helpers.d.ts +29 -0
- package/lib/test-helpers/device-model-test-helpers.d.ts.map +1 -0
- package/lib/test-helpers/device-model-test-helpers.js +116 -0
- package/lib/yoto-account.d.ts +339 -9
- package/lib/yoto-account.d.ts.map +1 -1
- package/lib/yoto-account.js +411 -39
- package/lib/yoto-account.test.js +139 -0
- package/lib/yoto-device.d.ts +31 -3
- package/lib/yoto-device.d.ts.map +1 -1
- package/lib/yoto-device.js +33 -11
- package/lib/yoto-device.test.js +6 -111
- package/package.json +1 -1
package/lib/yoto-account.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @import { YotoClientConstructorOptions } from './api-client.js'
|
|
3
|
-
* @import { YotoDeviceModelOptions } from './yoto-device.js'
|
|
3
|
+
* @import { YotoDeviceModelOptions, YotoDeviceModelConfig, YotoDeviceStatus, YotoDeviceOnlineMetadata, YotoDeviceOfflineMetadata, YotoPlaybackState, YotoMqttConnectMetadata, YotoMqttDisconnectMetadata, YotoMqttCloseMetadata } from './yoto-device.js'
|
|
4
|
+
* @import { YotoEventsMessage, YotoStatusMessage, YotoStatusLegacyMessage, YotoResponseMessage } from './mqtt/client.js'
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import { EventEmitter } from 'events'
|
|
@@ -33,14 +34,189 @@ import { YotoDeviceModel } from './yoto-device.js'
|
|
|
33
34
|
* @property {string[]} devices - Array of device IDs
|
|
34
35
|
*/
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Device event wrapper
|
|
39
|
+
* @template T
|
|
40
|
+
* @typedef {{ deviceId: string } & T} YotoAccountDeviceEvent
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Device added event data
|
|
45
|
+
* @typedef {YotoAccountDeviceEvent<{}>} YotoAccountDeviceAddedEvent
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Device removed event data
|
|
50
|
+
* @typedef {YotoAccountDeviceEvent<{}>} YotoAccountDeviceRemovedEvent
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Device status update event data
|
|
55
|
+
* @typedef {YotoAccountDeviceEvent<{
|
|
56
|
+
* status: YotoDeviceStatus,
|
|
57
|
+
* source: string,
|
|
58
|
+
* changedFields: Set<keyof YotoDeviceStatus>
|
|
59
|
+
* }>} YotoAccountStatusUpdateEvent
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Device config update event data
|
|
64
|
+
* @typedef {YotoAccountDeviceEvent<{
|
|
65
|
+
* config: YotoDeviceModelConfig,
|
|
66
|
+
* changedFields: Set<keyof YotoDeviceModelConfig>
|
|
67
|
+
* }>} YotoAccountConfigUpdateEvent
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Device playback update event data
|
|
72
|
+
* @typedef {YotoAccountDeviceEvent<{
|
|
73
|
+
* playback: YotoPlaybackState,
|
|
74
|
+
* changedFields: Set<keyof YotoPlaybackState>
|
|
75
|
+
* }>} YotoAccountPlaybackUpdateEvent
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Device online event data
|
|
80
|
+
* @typedef {YotoAccountDeviceEvent<{
|
|
81
|
+
* metadata: YotoDeviceOnlineMetadata
|
|
82
|
+
* }>} YotoAccountOnlineEvent
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Device offline event data
|
|
87
|
+
* @typedef {YotoAccountDeviceEvent<{
|
|
88
|
+
* metadata: YotoDeviceOfflineMetadata
|
|
89
|
+
* }>} YotoAccountOfflineEvent
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* MQTT connect event data
|
|
94
|
+
* @typedef {YotoAccountDeviceEvent<{
|
|
95
|
+
* metadata: YotoMqttConnectMetadata
|
|
96
|
+
* }>} YotoAccountMqttConnectEvent
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* MQTT disconnect event data
|
|
101
|
+
* @typedef {YotoAccountDeviceEvent<{
|
|
102
|
+
* metadata: YotoMqttDisconnectMetadata
|
|
103
|
+
* }>} YotoAccountMqttDisconnectEvent
|
|
104
|
+
*/
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* MQTT close event data
|
|
108
|
+
* @typedef {YotoAccountDeviceEvent<{
|
|
109
|
+
* metadata: YotoMqttCloseMetadata
|
|
110
|
+
* }>} YotoAccountMqttCloseEvent
|
|
111
|
+
*/
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* MQTT reconnect event data
|
|
115
|
+
* @typedef {YotoAccountDeviceEvent<{}>} YotoAccountMqttReconnectEvent
|
|
116
|
+
*/
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* MQTT offline event data
|
|
120
|
+
* @typedef {YotoAccountDeviceEvent<{}>} YotoAccountMqttOfflineEvent
|
|
121
|
+
*/
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* MQTT end event data
|
|
125
|
+
* @typedef {YotoAccountDeviceEvent<{}>} YotoAccountMqttEndEvent
|
|
126
|
+
*/
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* MQTT status event data
|
|
130
|
+
* @typedef {YotoAccountDeviceEvent<{
|
|
131
|
+
* topic: string,
|
|
132
|
+
* message: YotoStatusMessage
|
|
133
|
+
* }>} YotoAccountMqttStatusEvent
|
|
134
|
+
*/
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* MQTT events event data
|
|
138
|
+
* @typedef {YotoAccountDeviceEvent<{
|
|
139
|
+
* topic: string,
|
|
140
|
+
* message: YotoEventsMessage
|
|
141
|
+
* }>} YotoAccountMqttEventsEvent
|
|
142
|
+
*/
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* MQTT legacy status event data
|
|
146
|
+
* @typedef {YotoAccountDeviceEvent<{
|
|
147
|
+
* topic: string,
|
|
148
|
+
* message: YotoStatusLegacyMessage
|
|
149
|
+
* }>} YotoAccountMqttStatusLegacyEvent
|
|
150
|
+
*/
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* MQTT response event data
|
|
154
|
+
* @typedef {YotoAccountDeviceEvent<{
|
|
155
|
+
* topic: string,
|
|
156
|
+
* message: YotoResponseMessage
|
|
157
|
+
* }>} YotoAccountMqttResponseEvent
|
|
158
|
+
*/
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* MQTT unknown event data
|
|
162
|
+
* @typedef {YotoAccountDeviceEvent<{
|
|
163
|
+
* topic: string,
|
|
164
|
+
* message: unknown
|
|
165
|
+
* }>} YotoAccountMqttUnknownEvent
|
|
166
|
+
*/
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Account error event data
|
|
170
|
+
* @typedef {Object} YotoAccountErrorEvent
|
|
171
|
+
* @property {Error} error - Error instance
|
|
172
|
+
* @property {YotoAccountErrorContext} context - Error context
|
|
173
|
+
*/
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Device event handler set
|
|
177
|
+
* @typedef {Object} YotoAccountDeviceEventHandlers
|
|
178
|
+
* @property {(status: YotoDeviceStatus, source: string, changedFields: Set<keyof YotoDeviceStatus>) => void} statusUpdate
|
|
179
|
+
* @property {(config: YotoDeviceModelConfig, changedFields: Set<keyof YotoDeviceModelConfig>) => void} configUpdate
|
|
180
|
+
* @property {(playback: YotoPlaybackState, changedFields: Set<keyof YotoPlaybackState>) => void} playbackUpdate
|
|
181
|
+
* @property {(metadata: YotoDeviceOnlineMetadata) => void} online
|
|
182
|
+
* @property {(metadata: YotoDeviceOfflineMetadata) => void} offline
|
|
183
|
+
* @property {(metadata: YotoMqttConnectMetadata) => void} mqttConnect
|
|
184
|
+
* @property {(metadata: YotoMqttDisconnectMetadata) => void} mqttDisconnect
|
|
185
|
+
* @property {(metadata: YotoMqttCloseMetadata) => void} mqttClose
|
|
186
|
+
* @property {() => void} mqttReconnect
|
|
187
|
+
* @property {() => void} mqttOffline
|
|
188
|
+
* @property {() => void} mqttEnd
|
|
189
|
+
* @property {(topic: string, message: YotoStatusMessage) => void} mqttStatus
|
|
190
|
+
* @property {(topic: string, message: YotoEventsMessage) => void} mqttEvents
|
|
191
|
+
* @property {(topic: string, message: YotoStatusLegacyMessage) => void} mqttStatusLegacy
|
|
192
|
+
* @property {(topic: string, message: YotoResponseMessage) => void} mqttResponse
|
|
193
|
+
* @property {(topic: string, message: unknown) => void} mqttUnknown
|
|
194
|
+
*/
|
|
195
|
+
|
|
36
196
|
/**
|
|
37
197
|
* Event map for YotoAccount
|
|
38
198
|
* @typedef {{
|
|
39
199
|
* 'started': [YotoAccountStartedMetadata],
|
|
40
200
|
* 'stopped': [],
|
|
41
|
-
* 'deviceAdded': [
|
|
42
|
-
* 'deviceRemoved': [
|
|
43
|
-
* '
|
|
201
|
+
* 'deviceAdded': [YotoAccountDeviceAddedEvent],
|
|
202
|
+
* 'deviceRemoved': [YotoAccountDeviceRemovedEvent],
|
|
203
|
+
* 'statusUpdate': [YotoAccountStatusUpdateEvent],
|
|
204
|
+
* 'configUpdate': [YotoAccountConfigUpdateEvent],
|
|
205
|
+
* 'playbackUpdate': [YotoAccountPlaybackUpdateEvent],
|
|
206
|
+
* 'online': [YotoAccountOnlineEvent],
|
|
207
|
+
* 'offline': [YotoAccountOfflineEvent],
|
|
208
|
+
* 'mqttConnect': [YotoAccountMqttConnectEvent],
|
|
209
|
+
* 'mqttDisconnect': [YotoAccountMqttDisconnectEvent],
|
|
210
|
+
* 'mqttClose': [YotoAccountMqttCloseEvent],
|
|
211
|
+
* 'mqttReconnect': [YotoAccountMqttReconnectEvent],
|
|
212
|
+
* 'mqttOffline': [YotoAccountMqttOfflineEvent],
|
|
213
|
+
* 'mqttEnd': [YotoAccountMqttEndEvent],
|
|
214
|
+
* 'mqttStatus': [YotoAccountMqttStatusEvent],
|
|
215
|
+
* 'mqttEvents': [YotoAccountMqttEventsEvent],
|
|
216
|
+
* 'mqttStatusLegacy': [YotoAccountMqttStatusLegacyEvent],
|
|
217
|
+
* 'mqttResponse': [YotoAccountMqttResponseEvent],
|
|
218
|
+
* 'mqttUnknown': [YotoAccountMqttUnknownEvent],
|
|
219
|
+
* 'error': [YotoAccountErrorEvent]
|
|
44
220
|
* }} YotoAccountEventMap
|
|
45
221
|
*/
|
|
46
222
|
|
|
@@ -54,9 +230,10 @@ import { YotoDeviceModel } from './yoto-device.js'
|
|
|
54
230
|
* Events:
|
|
55
231
|
* - 'started' - Emitted when account starts, passes metadata with deviceCount and devices array
|
|
56
232
|
* - 'stopped' - Emitted when account stops
|
|
57
|
-
* - 'deviceAdded' - Emitted when a device is added, passes
|
|
58
|
-
* - 'deviceRemoved' - Emitted when a device is removed, passes deviceId
|
|
59
|
-
* -
|
|
233
|
+
* - 'deviceAdded' - Emitted when a device is added, passes { deviceId }
|
|
234
|
+
* - 'deviceRemoved' - Emitted when a device is removed, passes { deviceId }
|
|
235
|
+
* - Device events are re-emitted with device context, see event map for signatures
|
|
236
|
+
* - 'error' - Emitted when an error occurs, passes { error, context }
|
|
60
237
|
*
|
|
61
238
|
* Note: To listen to individual device events (statusUpdate, configUpdate, playbackUpdate, online, offline, etc.),
|
|
62
239
|
* access the device models directly via account.devices or account.getDevice(deviceId) and attach listeners.
|
|
@@ -66,6 +243,7 @@ import { YotoDeviceModel } from './yoto-device.js'
|
|
|
66
243
|
export class YotoAccount extends EventEmitter {
|
|
67
244
|
/** @type {YotoClient} */ #client
|
|
68
245
|
/** @type {Map<string, YotoDeviceModel>} */ #devices = new Map()
|
|
246
|
+
/** @type {Map<string, YotoAccountDeviceEventHandlers>} */ #deviceEventHandlers = new Map()
|
|
69
247
|
/** @type {YotoAccountOptions} */ #options
|
|
70
248
|
/** @type {boolean} */ #running = false
|
|
71
249
|
/** @type {boolean} */ #initialized = false
|
|
@@ -152,6 +330,7 @@ export class YotoAccount extends EventEmitter {
|
|
|
152
330
|
|
|
153
331
|
// Set up device error forwarding
|
|
154
332
|
this.#setupDeviceErrorHandling(deviceModel, device.deviceId)
|
|
333
|
+
this.#setupDeviceEventForwarding(deviceModel, device.deviceId)
|
|
155
334
|
|
|
156
335
|
// Track device
|
|
157
336
|
this.#devices.set(device.deviceId, deviceModel)
|
|
@@ -161,14 +340,19 @@ export class YotoAccount extends EventEmitter {
|
|
|
161
340
|
await deviceModel.start()
|
|
162
341
|
} catch (err) {
|
|
163
342
|
const error = /** @type {Error} */ (err)
|
|
164
|
-
this.emit('error',
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
343
|
+
this.emit('error', {
|
|
344
|
+
error,
|
|
345
|
+
context: {
|
|
346
|
+
source: device.deviceId,
|
|
347
|
+
deviceId: device.deviceId,
|
|
348
|
+
operation: 'start'
|
|
349
|
+
}
|
|
168
350
|
})
|
|
169
351
|
}
|
|
170
352
|
|
|
171
|
-
this.emit('deviceAdded',
|
|
353
|
+
this.emit('deviceAdded', {
|
|
354
|
+
deviceId: device.deviceId
|
|
355
|
+
})
|
|
172
356
|
}
|
|
173
357
|
|
|
174
358
|
// Mark as initialized and running
|
|
@@ -181,9 +365,12 @@ export class YotoAccount extends EventEmitter {
|
|
|
181
365
|
})
|
|
182
366
|
} catch (err) {
|
|
183
367
|
const error = /** @type {Error} */ (err)
|
|
184
|
-
this.emit('error',
|
|
185
|
-
|
|
186
|
-
|
|
368
|
+
this.emit('error', {
|
|
369
|
+
error,
|
|
370
|
+
context: {
|
|
371
|
+
source: 'account',
|
|
372
|
+
operation: 'start'
|
|
373
|
+
}
|
|
187
374
|
})
|
|
188
375
|
throw error
|
|
189
376
|
}
|
|
@@ -202,12 +389,16 @@ export class YotoAccount extends EventEmitter {
|
|
|
202
389
|
// Stop all devices
|
|
203
390
|
const stopPromises = []
|
|
204
391
|
for (const [deviceId, deviceModel] of this.#devices) {
|
|
392
|
+
this.#removeDeviceEventForwarding(deviceModel, deviceId)
|
|
205
393
|
stopPromises.push(
|
|
206
394
|
deviceModel.stop().catch((error) => {
|
|
207
|
-
this.emit('error',
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
395
|
+
this.emit('error', {
|
|
396
|
+
error,
|
|
397
|
+
context: {
|
|
398
|
+
source: deviceId,
|
|
399
|
+
deviceId,
|
|
400
|
+
operation: 'stop'
|
|
401
|
+
}
|
|
211
402
|
})
|
|
212
403
|
})
|
|
213
404
|
)
|
|
@@ -218,6 +409,7 @@ export class YotoAccount extends EventEmitter {
|
|
|
218
409
|
|
|
219
410
|
// Clear devices
|
|
220
411
|
this.#devices.clear()
|
|
412
|
+
this.#deviceEventHandlers.clear()
|
|
221
413
|
|
|
222
414
|
// Mark as stopped
|
|
223
415
|
this.#running = false
|
|
@@ -225,9 +417,12 @@ export class YotoAccount extends EventEmitter {
|
|
|
225
417
|
this.emit('stopped')
|
|
226
418
|
} catch (err) {
|
|
227
419
|
const error = /** @type {Error} */ (err)
|
|
228
|
-
this.emit('error',
|
|
229
|
-
|
|
230
|
-
|
|
420
|
+
this.emit('error', {
|
|
421
|
+
error,
|
|
422
|
+
context: {
|
|
423
|
+
source: 'account',
|
|
424
|
+
operation: 'stop'
|
|
425
|
+
}
|
|
231
426
|
})
|
|
232
427
|
throw error
|
|
233
428
|
}
|
|
@@ -271,15 +466,19 @@ export class YotoAccount extends EventEmitter {
|
|
|
271
466
|
for (const deviceId of devicesToRemove) {
|
|
272
467
|
const deviceModel = this.#devices.get(deviceId)
|
|
273
468
|
if (deviceModel) {
|
|
469
|
+
this.#removeDeviceEventForwarding(deviceModel, deviceId)
|
|
274
470
|
await deviceModel.stop().catch((error) => {
|
|
275
|
-
this.emit('error',
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
471
|
+
this.emit('error', {
|
|
472
|
+
error,
|
|
473
|
+
context: {
|
|
474
|
+
source: deviceId,
|
|
475
|
+
deviceId,
|
|
476
|
+
operation: 'remove'
|
|
477
|
+
}
|
|
279
478
|
})
|
|
280
479
|
})
|
|
281
480
|
this.#devices.delete(deviceId)
|
|
282
|
-
this.emit('deviceRemoved', deviceId)
|
|
481
|
+
this.emit('deviceRemoved', { deviceId })
|
|
283
482
|
}
|
|
284
483
|
}
|
|
285
484
|
|
|
@@ -293,26 +492,35 @@ export class YotoAccount extends EventEmitter {
|
|
|
293
492
|
|
|
294
493
|
// Set up device error forwarding
|
|
295
494
|
this.#setupDeviceErrorHandling(deviceModel, device.deviceId)
|
|
495
|
+
this.#setupDeviceEventForwarding(deviceModel, device.deviceId)
|
|
296
496
|
|
|
297
497
|
// Track device
|
|
298
498
|
this.#devices.set(device.deviceId, deviceModel)
|
|
299
499
|
|
|
300
500
|
// Start device
|
|
301
501
|
await deviceModel.start().catch((error) => {
|
|
302
|
-
this.emit('error',
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
502
|
+
this.emit('error', {
|
|
503
|
+
error,
|
|
504
|
+
context: {
|
|
505
|
+
source: device.deviceId,
|
|
506
|
+
deviceId: device.deviceId,
|
|
507
|
+
operation: 'add'
|
|
508
|
+
}
|
|
306
509
|
})
|
|
307
510
|
})
|
|
308
511
|
|
|
309
|
-
this.emit('deviceAdded',
|
|
512
|
+
this.emit('deviceAdded', {
|
|
513
|
+
deviceId: device.deviceId
|
|
514
|
+
})
|
|
310
515
|
}
|
|
311
516
|
} catch (err) {
|
|
312
517
|
const error = /** @type {Error} */ (err)
|
|
313
|
-
this.emit('error',
|
|
314
|
-
|
|
315
|
-
|
|
518
|
+
this.emit('error', {
|
|
519
|
+
error,
|
|
520
|
+
context: {
|
|
521
|
+
source: 'account',
|
|
522
|
+
operation: 'refreshDevices'
|
|
523
|
+
}
|
|
316
524
|
})
|
|
317
525
|
throw error
|
|
318
526
|
}
|
|
@@ -330,11 +538,175 @@ export class YotoAccount extends EventEmitter {
|
|
|
330
538
|
#setupDeviceErrorHandling (deviceModel, deviceId) {
|
|
331
539
|
// Forward device errors to account-level error event
|
|
332
540
|
deviceModel.on('error', (error) => {
|
|
333
|
-
this.emit('error',
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
541
|
+
this.emit('error', {
|
|
542
|
+
error,
|
|
543
|
+
context: {
|
|
544
|
+
source: deviceId,
|
|
545
|
+
deviceId,
|
|
546
|
+
operation: 'device-error'
|
|
547
|
+
}
|
|
337
548
|
})
|
|
338
549
|
})
|
|
339
550
|
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Forward device events through the account event bus with device context
|
|
554
|
+
* @param {YotoDeviceModel} deviceModel - Device model instance
|
|
555
|
+
* @param {string} deviceId - Device ID
|
|
556
|
+
*/
|
|
557
|
+
#setupDeviceEventForwarding (deviceModel, deviceId) {
|
|
558
|
+
/** @type {YotoAccountDeviceEventHandlers} */
|
|
559
|
+
const handlers = {
|
|
560
|
+
statusUpdate: (status, source, changedFields) => {
|
|
561
|
+
this.emit('statusUpdate', {
|
|
562
|
+
deviceId,
|
|
563
|
+
status,
|
|
564
|
+
source,
|
|
565
|
+
changedFields
|
|
566
|
+
})
|
|
567
|
+
},
|
|
568
|
+
configUpdate: (config, changedFields) => {
|
|
569
|
+
this.emit('configUpdate', {
|
|
570
|
+
deviceId,
|
|
571
|
+
config,
|
|
572
|
+
changedFields
|
|
573
|
+
})
|
|
574
|
+
},
|
|
575
|
+
playbackUpdate: (playback, changedFields) => {
|
|
576
|
+
this.emit('playbackUpdate', {
|
|
577
|
+
deviceId,
|
|
578
|
+
playback,
|
|
579
|
+
changedFields
|
|
580
|
+
})
|
|
581
|
+
},
|
|
582
|
+
online: (metadata) => {
|
|
583
|
+
this.emit('online', {
|
|
584
|
+
deviceId,
|
|
585
|
+
metadata
|
|
586
|
+
})
|
|
587
|
+
},
|
|
588
|
+
offline: (metadata) => {
|
|
589
|
+
this.emit('offline', {
|
|
590
|
+
deviceId,
|
|
591
|
+
metadata
|
|
592
|
+
})
|
|
593
|
+
},
|
|
594
|
+
mqttConnect: (metadata) => {
|
|
595
|
+
this.emit('mqttConnect', {
|
|
596
|
+
deviceId,
|
|
597
|
+
metadata
|
|
598
|
+
})
|
|
599
|
+
},
|
|
600
|
+
mqttDisconnect: (metadata) => {
|
|
601
|
+
this.emit('mqttDisconnect', {
|
|
602
|
+
deviceId,
|
|
603
|
+
metadata
|
|
604
|
+
})
|
|
605
|
+
},
|
|
606
|
+
mqttClose: (metadata) => {
|
|
607
|
+
this.emit('mqttClose', {
|
|
608
|
+
deviceId,
|
|
609
|
+
metadata
|
|
610
|
+
})
|
|
611
|
+
},
|
|
612
|
+
mqttReconnect: () => {
|
|
613
|
+
this.emit('mqttReconnect', {
|
|
614
|
+
deviceId
|
|
615
|
+
})
|
|
616
|
+
},
|
|
617
|
+
mqttOffline: () => {
|
|
618
|
+
this.emit('mqttOffline', {
|
|
619
|
+
deviceId
|
|
620
|
+
})
|
|
621
|
+
},
|
|
622
|
+
mqttEnd: () => {
|
|
623
|
+
this.emit('mqttEnd', {
|
|
624
|
+
deviceId
|
|
625
|
+
})
|
|
626
|
+
},
|
|
627
|
+
mqttStatus: (topic, message) => {
|
|
628
|
+
this.emit('mqttStatus', {
|
|
629
|
+
deviceId,
|
|
630
|
+
topic,
|
|
631
|
+
message
|
|
632
|
+
})
|
|
633
|
+
},
|
|
634
|
+
mqttEvents: (topic, message) => {
|
|
635
|
+
this.emit('mqttEvents', {
|
|
636
|
+
deviceId,
|
|
637
|
+
topic,
|
|
638
|
+
message
|
|
639
|
+
})
|
|
640
|
+
},
|
|
641
|
+
mqttStatusLegacy: (topic, message) => {
|
|
642
|
+
this.emit('mqttStatusLegacy', {
|
|
643
|
+
deviceId,
|
|
644
|
+
topic,
|
|
645
|
+
message
|
|
646
|
+
})
|
|
647
|
+
},
|
|
648
|
+
mqttResponse: (topic, message) => {
|
|
649
|
+
this.emit('mqttResponse', {
|
|
650
|
+
deviceId,
|
|
651
|
+
topic,
|
|
652
|
+
message
|
|
653
|
+
})
|
|
654
|
+
},
|
|
655
|
+
mqttUnknown: (topic, message) => {
|
|
656
|
+
this.emit('mqttUnknown', {
|
|
657
|
+
deviceId,
|
|
658
|
+
topic,
|
|
659
|
+
message
|
|
660
|
+
})
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
deviceModel.on('statusUpdate', handlers.statusUpdate)
|
|
665
|
+
deviceModel.on('configUpdate', handlers.configUpdate)
|
|
666
|
+
deviceModel.on('playbackUpdate', handlers.playbackUpdate)
|
|
667
|
+
deviceModel.on('online', handlers.online)
|
|
668
|
+
deviceModel.on('offline', handlers.offline)
|
|
669
|
+
deviceModel.on('mqttConnect', handlers.mqttConnect)
|
|
670
|
+
deviceModel.on('mqttDisconnect', handlers.mqttDisconnect)
|
|
671
|
+
deviceModel.on('mqttClose', handlers.mqttClose)
|
|
672
|
+
deviceModel.on('mqttReconnect', handlers.mqttReconnect)
|
|
673
|
+
deviceModel.on('mqttOffline', handlers.mqttOffline)
|
|
674
|
+
deviceModel.on('mqttEnd', handlers.mqttEnd)
|
|
675
|
+
deviceModel.on('mqttStatus', handlers.mqttStatus)
|
|
676
|
+
deviceModel.on('mqttEvents', handlers.mqttEvents)
|
|
677
|
+
deviceModel.on('mqttStatusLegacy', handlers.mqttStatusLegacy)
|
|
678
|
+
deviceModel.on('mqttResponse', handlers.mqttResponse)
|
|
679
|
+
deviceModel.on('mqttUnknown', handlers.mqttUnknown)
|
|
680
|
+
|
|
681
|
+
this.#deviceEventHandlers.set(deviceId, handlers)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Remove forwarded device event handlers
|
|
686
|
+
* @param {YotoDeviceModel} deviceModel - Device model instance
|
|
687
|
+
* @param {string} deviceId - Device ID
|
|
688
|
+
*/
|
|
689
|
+
#removeDeviceEventForwarding (deviceModel, deviceId) {
|
|
690
|
+
const handlers = this.#deviceEventHandlers.get(deviceId)
|
|
691
|
+
if (!handlers) return
|
|
692
|
+
|
|
693
|
+
deviceModel.off('statusUpdate', handlers.statusUpdate)
|
|
694
|
+
deviceModel.off('configUpdate', handlers.configUpdate)
|
|
695
|
+
deviceModel.off('playbackUpdate', handlers.playbackUpdate)
|
|
696
|
+
deviceModel.off('online', handlers.online)
|
|
697
|
+
deviceModel.off('offline', handlers.offline)
|
|
698
|
+
deviceModel.off('mqttConnect', handlers.mqttConnect)
|
|
699
|
+
deviceModel.off('mqttDisconnect', handlers.mqttDisconnect)
|
|
700
|
+
deviceModel.off('mqttClose', handlers.mqttClose)
|
|
701
|
+
deviceModel.off('mqttReconnect', handlers.mqttReconnect)
|
|
702
|
+
deviceModel.off('mqttOffline', handlers.mqttOffline)
|
|
703
|
+
deviceModel.off('mqttEnd', handlers.mqttEnd)
|
|
704
|
+
deviceModel.off('mqttStatus', handlers.mqttStatus)
|
|
705
|
+
deviceModel.off('mqttEvents', handlers.mqttEvents)
|
|
706
|
+
deviceModel.off('mqttStatusLegacy', handlers.mqttStatusLegacy)
|
|
707
|
+
deviceModel.off('mqttResponse', handlers.mqttResponse)
|
|
708
|
+
deviceModel.off('mqttUnknown', handlers.mqttUnknown)
|
|
709
|
+
|
|
710
|
+
this.#deviceEventHandlers.delete(deviceId)
|
|
711
|
+
}
|
|
340
712
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/** @import { YotoDeviceModel } from './yoto-device.js' */
|
|
2
|
+
|
|
3
|
+
import test from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { once } from 'node:events'
|
|
6
|
+
import { setTimeout as sleep } from 'node:timers/promises'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { YotoAccount } from './yoto-account.js'
|
|
9
|
+
import { loadTestTokens } from './api-endpoints/endpoint-test-helpers.js'
|
|
10
|
+
import { saveTokensToEnv } from '../bin/lib/token-helpers.js'
|
|
11
|
+
import {
|
|
12
|
+
assertConfigShape,
|
|
13
|
+
assertPlaybackShape,
|
|
14
|
+
toLower,
|
|
15
|
+
withTimeout
|
|
16
|
+
} from './test-helpers/device-model-test-helpers.js'
|
|
17
|
+
|
|
18
|
+
const envPath = join(import.meta.dirname, '..', '.env')
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @returns {YotoAccount}
|
|
22
|
+
*/
|
|
23
|
+
function createTestAccount () {
|
|
24
|
+
const clientId = process.env['YOTO_CLIENT_ID']
|
|
25
|
+
const refreshToken = process.env['YOTO_REFRESH_TOKEN']
|
|
26
|
+
const accessToken = process.env['YOTO_ACCESS_TOKEN']
|
|
27
|
+
|
|
28
|
+
assert.ok(clientId, 'YOTO_CLIENT_ID is required')
|
|
29
|
+
assert.ok(refreshToken, 'YOTO_REFRESH_TOKEN is required')
|
|
30
|
+
assert.ok(accessToken, 'YOTO_ACCESS_TOKEN is required')
|
|
31
|
+
|
|
32
|
+
return new YotoAccount({
|
|
33
|
+
clientOptions: {
|
|
34
|
+
clientId,
|
|
35
|
+
refreshToken,
|
|
36
|
+
accessToken,
|
|
37
|
+
onTokenRefresh: async (tokens) => {
|
|
38
|
+
const { resolvedPath } = await saveTokensToEnv(envPath, {
|
|
39
|
+
access_token: tokens.updatedAccessToken,
|
|
40
|
+
refresh_token: tokens.updatedRefreshToken,
|
|
41
|
+
token_type: 'Bearer',
|
|
42
|
+
expires_in: tokens.updatedExpiresAt - Math.floor(Date.now() / 1000)
|
|
43
|
+
}, tokens.clientId)
|
|
44
|
+
console.log(`Token Refreshed: ${resolvedPath}`)
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
deviceOptions: {
|
|
48
|
+
httpPollIntervalMs: 600000
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {YotoAccount} account
|
|
55
|
+
* @returns {Promise<void>}
|
|
56
|
+
*/
|
|
57
|
+
async function waitForAccountReady (account) {
|
|
58
|
+
const started = withTimeout(once(account, 'started'), 30000, 'started')
|
|
59
|
+
await account.start()
|
|
60
|
+
await started
|
|
61
|
+
await sleep(1500)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {YotoDeviceModel} model
|
|
66
|
+
*/
|
|
67
|
+
function assertDeviceModelState (model) {
|
|
68
|
+
assert.ok(model.running, 'device model should be running')
|
|
69
|
+
assertConfigShape(model.config)
|
|
70
|
+
assertPlaybackShape(model.playback)
|
|
71
|
+
|
|
72
|
+
// TODO: Add mutation coverage (updateConfig, sendCommand, etc.)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
test('YotoAccount - online devices', async (t) => {
|
|
76
|
+
/** @type {YotoAccount | null} */
|
|
77
|
+
let account = null
|
|
78
|
+
/** @type {YotoDeviceModel[]} */
|
|
79
|
+
let deviceModels = []
|
|
80
|
+
/** @type {{ deviceId: string }[]} */
|
|
81
|
+
const deviceAddedEvents = []
|
|
82
|
+
/** @type {YotoDeviceModel | undefined} */
|
|
83
|
+
let onlineMini
|
|
84
|
+
/** @type {YotoDeviceModel | undefined} */
|
|
85
|
+
let onlineV3
|
|
86
|
+
|
|
87
|
+
t.before(async () => {
|
|
88
|
+
loadTestTokens()
|
|
89
|
+
account = createTestAccount()
|
|
90
|
+
account.on('deviceAdded', (event) => {
|
|
91
|
+
deviceAddedEvents.push(event)
|
|
92
|
+
})
|
|
93
|
+
await waitForAccountReady(account)
|
|
94
|
+
|
|
95
|
+
deviceModels = Array.from(account.devices.values())
|
|
96
|
+
onlineMini = deviceModels.find(model => {
|
|
97
|
+
const { deviceType, deviceFamily, online } = model.device
|
|
98
|
+
const family = toLower(deviceFamily)
|
|
99
|
+
const type = toLower(deviceType)
|
|
100
|
+
return online && (family === 'mini' || type.includes('mini'))
|
|
101
|
+
})
|
|
102
|
+
onlineV3 = deviceModels.find(model => {
|
|
103
|
+
const { deviceType, deviceFamily, online } = model.device
|
|
104
|
+
const family = toLower(deviceFamily)
|
|
105
|
+
const type = toLower(deviceType)
|
|
106
|
+
return online && (family === 'v3' || type.includes('v3'))
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
t.after(async () => {
|
|
111
|
+
if (account) {
|
|
112
|
+
await account.stop()
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
await t.test('account state', async () => {
|
|
117
|
+
assert.ok(account, 'account should exist')
|
|
118
|
+
assert.ok(account.running, 'account should be running')
|
|
119
|
+
assert.ok(account.initialized, 'account should be initialized')
|
|
120
|
+
assert.ok(account.devices.size > 0, 'account should have devices')
|
|
121
|
+
assert.equal(account.getDeviceIds().length, account.devices.size, 'device IDs should match map size')
|
|
122
|
+
assert.equal(deviceAddedEvents.length, account.devices.size, 'deviceAdded events should match device count')
|
|
123
|
+
for (const event of deviceAddedEvents) {
|
|
124
|
+
assert.equal(typeof event.deviceId, 'string', 'deviceAdded should include a deviceId')
|
|
125
|
+
assert.ok(!('deviceModel' in event), 'deviceAdded should not include deviceModel')
|
|
126
|
+
assert.equal(Object.keys(event).length, 1, 'deviceAdded should only include deviceId')
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
await t.test('online mini', { skip: !onlineMini }, async () => {
|
|
131
|
+
assert.ok(onlineMini, 'No online mini device found')
|
|
132
|
+
assertDeviceModelState(onlineMini)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
await t.test('online v3', { skip: !onlineV3 }, async () => {
|
|
136
|
+
assert.ok(onlineV3, 'No online v3 device found')
|
|
137
|
+
assertDeviceModelState(onlineV3)
|
|
138
|
+
})
|
|
139
|
+
})
|