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.
@@ -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': [string, YotoDeviceModel],
42
- * 'deviceRemoved': [string],
43
- * 'error': [Error, YotoAccountErrorContext]
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 (deviceId, deviceModel)
58
- * - 'deviceRemoved' - Emitted when a device is removed, passes deviceId
59
- * - 'error' - Emitted when an error occurs, passes (error, context)
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', error, {
165
- source: device.deviceId,
166
- deviceId: device.deviceId,
167
- operation: 'start'
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', device.deviceId, deviceModel)
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', error, {
185
- source: 'account',
186
- operation: 'start'
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', error, {
208
- source: deviceId,
209
- deviceId,
210
- operation: 'stop'
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', error, {
229
- source: 'account',
230
- operation: 'stop'
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', error, {
276
- source: deviceId,
277
- deviceId,
278
- operation: 'remove'
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', error, {
303
- source: device.deviceId,
304
- deviceId: device.deviceId,
305
- operation: 'add'
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', device.deviceId, deviceModel)
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', error, {
314
- source: 'account',
315
- operation: 'refreshDevices'
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', error, {
334
- source: deviceId,
335
- deviceId,
336
- operation: 'device-error'
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
+ })