yoto-nodejs-client 0.0.2 → 0.0.3

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.
Files changed (75) hide show
  1. package/README.md +523 -30
  2. package/bin/auth.js +36 -46
  3. package/bin/content.js +0 -0
  4. package/bin/device-model.d.ts +3 -0
  5. package/bin/device-model.d.ts.map +1 -0
  6. package/bin/device-model.js +360 -0
  7. package/bin/device-tui.TODO.md +125 -0
  8. package/bin/device-tui.d.ts +31 -0
  9. package/bin/device-tui.d.ts.map +1 -0
  10. package/bin/device-tui.js +1123 -0
  11. package/bin/devices.js +166 -28
  12. package/bin/groups.js +0 -0
  13. package/bin/icons.js +0 -0
  14. package/bin/lib/cli-helpers.d.ts +1 -1
  15. package/bin/lib/cli-helpers.d.ts.map +1 -1
  16. package/bin/lib/cli-helpers.js +5 -5
  17. package/bin/refresh-token.js +6 -6
  18. package/bin/token-info.js +3 -3
  19. package/index.d.ts +4 -585
  20. package/index.d.ts.map +1 -1
  21. package/index.js +11 -689
  22. package/lib/api-client.d.ts +576 -0
  23. package/lib/api-client.d.ts.map +1 -0
  24. package/lib/api-client.js +681 -0
  25. package/lib/api-endpoints/auth.d.ts +199 -8
  26. package/lib/api-endpoints/auth.d.ts.map +1 -1
  27. package/lib/api-endpoints/auth.js +224 -7
  28. package/lib/api-endpoints/auth.test.js +54 -2
  29. package/lib/api-endpoints/constants.d.ts +14 -8
  30. package/lib/api-endpoints/constants.d.ts.map +1 -1
  31. package/lib/api-endpoints/constants.js +17 -10
  32. package/lib/api-endpoints/content.test.js +1 -1
  33. package/lib/api-endpoints/devices.d.ts +405 -117
  34. package/lib/api-endpoints/devices.d.ts.map +1 -1
  35. package/lib/api-endpoints/devices.js +114 -52
  36. package/lib/api-endpoints/devices.test.js +1 -1
  37. package/lib/api-endpoints/{test-helpers.d.ts → endpoint-test-helpers.d.ts} +1 -1
  38. package/lib/api-endpoints/endpoint-test-helpers.d.ts.map +1 -0
  39. package/lib/api-endpoints/family-library-groups.test.js +1 -1
  40. package/lib/api-endpoints/family.test.js +1 -1
  41. package/lib/api-endpoints/icons.test.js +1 -1
  42. package/lib/helpers/power-state.d.ts +53 -0
  43. package/lib/helpers/power-state.d.ts.map +1 -0
  44. package/lib/helpers/power-state.js +73 -0
  45. package/lib/helpers/power-state.test.js +100 -0
  46. package/lib/helpers/temperature.d.ts +24 -0
  47. package/lib/helpers/temperature.d.ts.map +1 -0
  48. package/lib/helpers/temperature.js +61 -0
  49. package/lib/helpers/temperature.test.js +58 -0
  50. package/lib/helpers/typed-keys.d.ts +7 -0
  51. package/lib/helpers/typed-keys.d.ts.map +1 -0
  52. package/lib/helpers/typed-keys.js +8 -0
  53. package/lib/mqtt/client.d.ts +348 -22
  54. package/lib/mqtt/client.d.ts.map +1 -1
  55. package/lib/mqtt/client.js +213 -31
  56. package/lib/mqtt/factory.d.ts +22 -4
  57. package/lib/mqtt/factory.d.ts.map +1 -1
  58. package/lib/mqtt/factory.js +27 -5
  59. package/lib/mqtt/mqtt.test.js +85 -28
  60. package/lib/mqtt/topics.d.ts +41 -13
  61. package/lib/mqtt/topics.d.ts.map +1 -1
  62. package/lib/mqtt/topics.js +54 -20
  63. package/lib/pkg.d.cts +9 -0
  64. package/lib/token.d.ts +21 -6
  65. package/lib/token.d.ts.map +1 -1
  66. package/lib/token.js +30 -23
  67. package/lib/yoto-account.d.ts +163 -0
  68. package/lib/yoto-account.d.ts.map +1 -0
  69. package/lib/yoto-account.js +340 -0
  70. package/lib/yoto-device.d.ts +656 -0
  71. package/lib/yoto-device.d.ts.map +1 -0
  72. package/lib/yoto-device.js +2850 -0
  73. package/package.json +22 -15
  74. package/lib/api-endpoints/test-helpers.d.ts.map +0 -1
  75. /package/lib/api-endpoints/{test-helpers.js → endpoint-test-helpers.js} +0 -0
@@ -0,0 +1,1123 @@
1
+ #!/usr/bin/env node
2
+
3
+ // This (specific) CLI is a vibecoded prototype, please don't rely on this.
4
+
5
+ /**
6
+ * @import { ArgscloptsParseArgsOptionsConfig } from 'argsclopts'
7
+ * @import { YotoDeviceModel } from '../lib/yoto-device.js'
8
+ */
9
+
10
+ import { Screen, Box, Text, Line, NodeRuntime, setRuntime } from '@unblessed/node'
11
+ import { printHelpText } from 'argsclopts'
12
+ import { parseArgs } from 'node:util'
13
+ import { pkg } from '../lib/pkg.cjs'
14
+ import {
15
+ getCommonOptions,
16
+ loadTokensFromEnv,
17
+ handleCliError
18
+ } from './lib/cli-helpers.js'
19
+ import { saveTokensToEnv } from './lib/token-helpers.js'
20
+ import { YotoAccount } from '../lib/yoto-account.js'
21
+ import { getNightlightColorName } from '../lib/yoto-device.js'
22
+
23
+ // Initialize the unblessed runtime before using any widgets
24
+ setRuntime(new NodeRuntime())
25
+
26
+ /** @type {ArgscloptsParseArgsOptionsConfig} */
27
+ const options = {
28
+ ...getCommonOptions(),
29
+ 'poll-interval': {
30
+ type: 'string',
31
+ short: 'p',
32
+ help: 'HTTP polling interval in milliseconds (default: 600000 / 10 minutes)'
33
+ },
34
+ duration: {
35
+ type: 'string',
36
+ short: 't',
37
+ help: 'How long to run in seconds (default: run indefinitely)'
38
+ }
39
+ }
40
+
41
+ const args = parseArgs({ options, strict: false, allowPositionals: true })
42
+
43
+ if (args.values['help']) {
44
+ await printHelpText({
45
+ options,
46
+ name: 'yoto-device-tui',
47
+ version: pkg.version,
48
+ exampleFn: ({ name }) => ` Yoto Device TUI - Interactive multi-device monitor
49
+
50
+ Examples:
51
+ ${name} # Monitor all devices
52
+ ${name} --duration 60 # Run for 60 seconds
53
+ ${name} --poll-interval 300000 # Poll every 5 minutes
54
+
55
+ Controls:
56
+ Arrow keys - Navigate between devices
57
+ Enter - View device details
58
+ Escape/b - Return to overview
59
+ q/Ctrl+C - Quit
60
+ `
61
+ })
62
+ process.exit(0)
63
+ }
64
+
65
+ // Load tokens from environment
66
+ const { clientId, refreshToken, accessToken, envFile } = loadTokensFromEnv(args)
67
+
68
+ const pollInterval = args.values['poll-interval'] ? Number(args.values['poll-interval']) : 600000
69
+ const duration = args.values['duration'] ? Number(args.values['duration']) : null
70
+
71
+ // ============================================================================
72
+ // Helper Functions
73
+ // ============================================================================
74
+
75
+ /**
76
+ * Format seconds to MM:SS
77
+ * @param {number | null | undefined} seconds
78
+ * @returns {string}
79
+ */
80
+ function formatTime (seconds) {
81
+ if (seconds == null || seconds < 0) return '--:--'
82
+ const mins = Math.floor(seconds / 60)
83
+ const secs = Math.floor(seconds % 60)
84
+ return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
85
+ }
86
+
87
+ /**
88
+ * Get battery display with charging indicator
89
+ * @param {number | null} level
90
+ * @param {boolean | null} charging
91
+ * @returns {string}
92
+ */
93
+ function formatBattery (level, charging) {
94
+ if (level == null) return '??%'
95
+ const icon = charging ? '⚡' : '🔋'
96
+ return `${icon} ${level}%`
97
+ }
98
+
99
+ /**
100
+ * Get compact battery display
101
+ * @param {number | null} level
102
+ * @param {boolean | null} charging
103
+ * @returns {string}
104
+ */
105
+ function formatBatteryCompact (level, charging) {
106
+ if (level == null) return '??%'
107
+ const icon = charging ? '⚡' : ''
108
+ return `${icon}${level}%`
109
+ }
110
+
111
+ /**
112
+ * Get volume bar display
113
+ * @param {number | null} volume
114
+ * @param {number | null} maxVolume
115
+ * @returns {string}
116
+ */
117
+ function formatVolume (volume, maxVolume) {
118
+ if (volume == null) return '🔊 --/--'
119
+ const max = maxVolume ?? 16
120
+ const filled = Math.round((volume / 16) * 10)
121
+ const bar = '█'.repeat(filled) + '░'.repeat(10 - filled)
122
+ return `🔊 [${bar}] ${volume}/${max}`
123
+ }
124
+
125
+ /**
126
+ * Get compact volume display
127
+ * @param {number | null} volume
128
+ * @returns {string}
129
+ */
130
+ function formatVolumeCompact (volume) {
131
+ if (volume == null) return 'Vol:--'
132
+ return `Vol:${volume}`
133
+ }
134
+
135
+ /**
136
+ * Get playback status icon
137
+ * @param {string | null} status
138
+ * @param {string | null} [cardId]
139
+ * @returns {string}
140
+ */
141
+ function getPlaybackIcon (status, cardId) {
142
+ // No card = ejected (cardId can be null or undefined)
143
+ const hasCard = cardId !== null
144
+ if (!hasCard && status !== 'playing' && status !== 'paused') {
145
+ return '⏏️'
146
+ }
147
+ switch (status) {
148
+ case 'playing': return '▶️'
149
+ case 'paused': return '⏸️'
150
+ case 'stopped': return '⏹️'
151
+ default: return '⏹️'
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Get power source display
157
+ * @param {'battery' | 'dock' | 'usb-c' | 'wireless'} source
158
+ * @returns {string}
159
+ */
160
+ function formatPowerSource (source) {
161
+ switch (source) {
162
+ case 'battery': return 'Battery'
163
+ case 'dock': return 'Dock'
164
+ case 'usb-c': return 'USB-C'
165
+ case 'wireless': return 'Wireless'
166
+ default: return 'Unknown'
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Get day mode display
172
+ * @param {'unknown' | 'night' | 'day'} mode
173
+ * @returns {string}
174
+ */
175
+ function formatDayMode (mode) {
176
+ switch (mode) {
177
+ case 'night': return '🌙 Night'
178
+ case 'day': return '☀️ Day'
179
+ default: return '❓ Unknown'
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Get compact day mode
185
+ * @param {'unknown' | 'night' | 'day'} mode
186
+ * @returns {string}
187
+ */
188
+ function formatDayModeCompact (mode) {
189
+ switch (mode) {
190
+ case 'night': return '🌙'
191
+ case 'day': return '☀️'
192
+ default: return '❓'
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Get nightlight display with color name
198
+ * @param {string} mode
199
+ * @returns {string}
200
+ */
201
+ function formatNightlight (mode) {
202
+ const colorName = getNightlightColorName(mode)
203
+ if (mode === 'off') {
204
+ return '💡 Off (screen up)'
205
+ } else if (mode === '0x000000') {
206
+ return '🌑 No Light (screen down)'
207
+ } else {
208
+ return `💡 ${colorName}`
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Get card insertion state display
214
+ * @param {'none' | 'physical' | 'remote'} state
215
+ * @returns {string}
216
+ */
217
+ function formatCardInsertion (state) {
218
+ switch (state) {
219
+ case 'none': return '⏏️ No Card'
220
+ case 'physical': return '💿 Physical'
221
+ case 'remote': return '☁️ Remote'
222
+ default: return '❓ Unknown'
223
+ }
224
+ }
225
+
226
+ // ============================================================================
227
+ // Device Card Component (for overview)
228
+ // ============================================================================
229
+
230
+ /**
231
+ * @typedef {Object} DeviceCard
232
+ * @property {Box} box
233
+ * @property {Text} nameText
234
+ * @property {Text} statusText
235
+ * @property {Text} batteryText
236
+ * @property {Text} playbackText
237
+ * @property {YotoDeviceModel} model
238
+ * @property {number} index
239
+ */
240
+
241
+ /**
242
+ * Create a device card for the overview
243
+ * @param {Box} parent
244
+ * @param {YotoDeviceModel} model
245
+ * @param {number} index
246
+ * @param {number} row
247
+ * @param {number} col
248
+ * @param {boolean} selected
249
+ * @returns {DeviceCard}
250
+ */
251
+ function createDeviceCard (parent, model, index, row, col, selected) {
252
+ const cardWidth = 28
253
+ const cardHeight = 8
254
+ const left = 1 + col * (cardWidth + 1)
255
+ const top = 1 + row * (cardHeight + 1)
256
+
257
+ const box = new Box({
258
+ parent,
259
+ top,
260
+ left,
261
+ width: cardWidth,
262
+ height: cardHeight,
263
+ border: { type: 'line' },
264
+ tags: true,
265
+ style: {
266
+ fg: 'white',
267
+ bg: '#1a1a2e',
268
+ border: { fg: selected ? '#ffcc00' : '#4a90d9' }
269
+ }
270
+ })
271
+
272
+ const nameText = new Text({
273
+ parent: box,
274
+ top: 0,
275
+ left: 1,
276
+ width: cardWidth - 4,
277
+ content: `{bold}${model.device.name.substring(0, cardWidth - 6)}{/bold}`,
278
+ tags: true,
279
+ style: { fg: selected ? '#ffcc00' : '#4a90d9', bg: '#1a1a2e' }
280
+ })
281
+
282
+ const statusText = new Text({
283
+ parent: box,
284
+ top: 1,
285
+ left: 1,
286
+ width: cardWidth - 4,
287
+ content: '📡 -- 🔌 --',
288
+ tags: true,
289
+ style: { fg: 'white', bg: '#1a1a2e' }
290
+ })
291
+
292
+ const batteryText = new Text({
293
+ parent: box,
294
+ top: 2,
295
+ left: 1,
296
+ width: cardWidth - 4,
297
+ content: '🔋 --% | Vol:--',
298
+ tags: true,
299
+ style: { fg: 'white', bg: '#1a1a2e' }
300
+ })
301
+
302
+ const playbackText = new Text({
303
+ parent: box,
304
+ top: 4,
305
+ left: 1,
306
+ width: cardWidth - 4,
307
+ content: '⏹️ --',
308
+ tags: true,
309
+ style: { fg: '#888888', bg: '#1a1a2e' }
310
+ })
311
+
312
+ return { box, nameText, statusText, batteryText, playbackText, model, index }
313
+ }
314
+
315
+ /**
316
+ * Update a device card display
317
+ * @param {DeviceCard} card
318
+ * @param {boolean} selected
319
+ */
320
+ function updateDeviceCard (card, selected) {
321
+ const { model, box, nameText, statusText, batteryText, playbackText } = card
322
+ const status = model.status
323
+ const playback = model.playback
324
+
325
+ // Update selection style
326
+ if (box.style.border) {
327
+ box.style.border.fg = selected ? '#ffcc00' : '#4a90d9'
328
+ }
329
+ nameText.style.fg = selected ? '#ffcc00' : '#4a90d9'
330
+
331
+ // Connection status
332
+ const onlineIcon = model.deviceOnline ? '{green-fg}●{/green-fg}' : '{red-fg}●{/red-fg}'
333
+ const mqttIcon = model.mqttConnected ? '{green-fg}●{/green-fg}' : '{red-fg}●{/red-fg}'
334
+ statusText.setContent(`📡${onlineIcon} 🔌${mqttIcon} ${formatDayModeCompact(status.dayMode)}`)
335
+
336
+ // Battery & Volume
337
+ const batteryStr = formatBatteryCompact(status.batteryLevelPercentage, status.isCharging)
338
+ const volStr = formatVolumeCompact(status.volume)
339
+ batteryText.setContent(`🔋${batteryStr} | ${volStr}`)
340
+
341
+ // Playback
342
+ const playIcon = getPlaybackIcon(playback.playbackStatus, playback.cardId)
343
+ const track = playback.trackTitle || 'Stopped'
344
+ playbackText.setContent(`${playIcon} ${track.substring(0, 20)}`)
345
+ }
346
+
347
+ // ============================================================================
348
+ // Detail View Component
349
+ // ============================================================================
350
+
351
+ /**
352
+ * @typedef {Object} DetailView
353
+ * @property {Box} container
354
+ * @property {Box} deviceBox
355
+ * @property {Text} connectionText
356
+ * @property {Text} batteryText
357
+ * @property {Text} volumeText
358
+ * @property {Text} modeText
359
+ * @property {Text} tempText
360
+ * @property {Text} playbackHeader
361
+ * @property {Text} trackText
362
+ * @property {Text} chapterText
363
+ * @property {Text} progressText
364
+ * @property {Text} cardText
365
+ * @property {Box} logBox
366
+ * @property {string[]} logLines
367
+ * @property {YotoDeviceModel | null} model
368
+ */
369
+
370
+ /**
371
+ * Create the detail view
372
+ * @param {Screen} screen
373
+ * @returns {DetailView}
374
+ */
375
+ function createDetailView (screen) {
376
+ const container = new Box({
377
+ parent: screen,
378
+ top: 0,
379
+ left: 0,
380
+ width: '100%',
381
+ height: '100%-1',
382
+ hidden: true,
383
+ style: { fg: 'white', bg: 'black' }
384
+ })
385
+
386
+ const deviceBox = new Box({
387
+ parent: container,
388
+ top: 1,
389
+ left: 'center',
390
+ width: 50,
391
+ height: 18,
392
+ border: { type: 'line' },
393
+ tags: true,
394
+ style: {
395
+ fg: 'white',
396
+ bg: '#1a1a2e',
397
+ border: { fg: '#4a90d9' }
398
+ }
399
+ })
400
+
401
+ /* eslint-disable-next-line no-new */
402
+ new Line({
403
+ parent: deviceBox,
404
+ top: 2,
405
+ left: 0,
406
+ width: 48,
407
+ orientation: 'horizontal',
408
+ style: { fg: '#4a90d9' }
409
+ })
410
+
411
+ const connectionText = new Text({
412
+ parent: deviceBox,
413
+ top: 3,
414
+ left: 2,
415
+ content: '📡 Online: -- | 🔌 MQTT: --',
416
+ tags: true,
417
+ style: { fg: 'white', bg: '#1a1a2e' }
418
+ })
419
+
420
+ const batteryText = new Text({
421
+ parent: deviceBox,
422
+ top: 5,
423
+ left: 2,
424
+ content: '🔋 --% | Power: --',
425
+ tags: true,
426
+ style: { fg: 'white', bg: '#1a1a2e' }
427
+ })
428
+
429
+ const volumeText = new Text({
430
+ parent: deviceBox,
431
+ top: 6,
432
+ left: 2,
433
+ content: '🔊 [░░░░░░░░░░] --/--',
434
+ tags: true,
435
+ style: { fg: 'white', bg: '#1a1a2e' }
436
+ })
437
+
438
+ const modeText = new Text({
439
+ parent: deviceBox,
440
+ top: 7,
441
+ left: 2,
442
+ content: '☀️ -- | 📶 -- dBm',
443
+ tags: true,
444
+ style: { fg: 'white', bg: '#1a1a2e' }
445
+ })
446
+
447
+ const tempText = new Text({
448
+ parent: deviceBox,
449
+ top: 8,
450
+ left: 2,
451
+ content: '🌡️ --°C',
452
+ tags: true,
453
+ style: { fg: 'white', bg: '#1a1a2e' }
454
+ })
455
+
456
+ /* eslint-disable-next-line no-new */
457
+ new Line({
458
+ parent: deviceBox,
459
+ top: 9,
460
+ left: 0,
461
+ width: 48,
462
+ orientation: 'horizontal',
463
+ style: { fg: '#4a90d9' }
464
+ })
465
+
466
+ const playbackHeader = new Text({
467
+ parent: deviceBox,
468
+ top: 10,
469
+ left: 2,
470
+ content: '{bold}▶️ Now Playing{/bold}',
471
+ tags: true,
472
+ style: { fg: '#4a90d9', bg: '#1a1a2e' }
473
+ })
474
+
475
+ const trackText = new Text({
476
+ parent: deviceBox,
477
+ top: 11,
478
+ left: 2,
479
+ width: 44,
480
+ content: ' Track: --',
481
+ tags: true,
482
+ style: { fg: 'white', bg: '#1a1a2e' }
483
+ })
484
+
485
+ const chapterText = new Text({
486
+ parent: deviceBox,
487
+ top: 12,
488
+ left: 2,
489
+ width: 44,
490
+ content: ' Chapter: --',
491
+ tags: true,
492
+ style: { fg: '#888888', bg: '#1a1a2e' }
493
+ })
494
+
495
+ const progressText = new Text({
496
+ parent: deviceBox,
497
+ top: 13,
498
+ left: 2,
499
+ content: ' ⏱️ --:-- / --:--',
500
+ tags: true,
501
+ style: { fg: 'white', bg: '#1a1a2e' }
502
+ })
503
+
504
+ const cardText = new Text({
505
+ parent: deviceBox,
506
+ top: 14,
507
+ left: 2,
508
+ width: 44,
509
+ content: ' 🎴 Card: --',
510
+ tags: true,
511
+ style: { fg: '#666666', bg: '#1a1a2e' }
512
+ })
513
+
514
+ const logBox = new Box({
515
+ parent: container,
516
+ top: 20,
517
+ left: 1,
518
+ width: '100%-2',
519
+ height: '100%-21',
520
+ border: { type: 'line' },
521
+ label: ' Event Log ',
522
+ tags: true,
523
+ style: {
524
+ fg: 'white',
525
+ bg: '#0a0a0a',
526
+ border: { fg: '#333333' },
527
+ label: { fg: '#666666' }
528
+ }
529
+ })
530
+
531
+ /** @type {string[]} */
532
+ const logLines = []
533
+
534
+ return {
535
+ container,
536
+ deviceBox,
537
+ connectionText,
538
+ batteryText,
539
+ volumeText,
540
+ modeText,
541
+ tempText,
542
+ playbackHeader,
543
+ trackText,
544
+ chapterText,
545
+ progressText,
546
+ cardText,
547
+ logBox,
548
+ logLines,
549
+ model: null
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Update detail view header with device name
555
+ * @param {DetailView} view
556
+ * @param {YotoDeviceModel} model
557
+ */
558
+ function setDetailViewModel (view, model) {
559
+ view.model = model
560
+
561
+ // Create header dynamically
562
+ const headerBox = view.deviceBox.children.find(c => c._isHeader)
563
+ if (headerBox) {
564
+ headerBox.destroy()
565
+ }
566
+
567
+ const header = new Box({
568
+ parent: view.deviceBox,
569
+ top: 0,
570
+ left: 'center',
571
+ width: 46,
572
+ height: 2,
573
+ content: `{center}{bold}${model.device.name}{/bold}\n${model.device.deviceType}{/center}`,
574
+ tags: true,
575
+ style: { fg: '#4a90d9', bg: '#1a1a2e' }
576
+ })
577
+ // @ts-expect-error - marking for identification
578
+ header._isHeader = true
579
+
580
+ view.logLines.length = 0
581
+ view.logBox.setContent('')
582
+ }
583
+
584
+ /**
585
+ * Update the detail view display
586
+ * @param {DetailView} view
587
+ */
588
+ function updateDetailView (view) {
589
+ const model = view.model
590
+ if (!model) return
591
+
592
+ const status = model.status
593
+ const playback = model.playback
594
+ const capabilities = model.capabilities
595
+
596
+ // Connection status
597
+ const onlineColor = model.deviceOnline ? '{green-fg}' : '{red-fg}'
598
+ const onlineStatus = model.deviceOnline ? 'Yes' : 'No'
599
+ const mqttColor = model.mqttConnected ? '{green-fg}' : '{red-fg}'
600
+ const mqttStatus = model.mqttConnected ? 'Yes' : 'No'
601
+ view.connectionText.setContent(`📡 Online: ${onlineColor}${onlineStatus}{/} | 🔌 MQTT: ${mqttColor}${mqttStatus}{/}`)
602
+
603
+ // Battery & Power
604
+ const batteryStr = formatBattery(status.batteryLevelPercentage, status.isCharging)
605
+ const powerStr = formatPowerSource(status.powerSource)
606
+ view.batteryText.setContent(`${batteryStr} | Power: ${powerStr}`)
607
+
608
+ // Volume
609
+ view.volumeText.setContent(formatVolume(status.volume, status.maxVolume))
610
+
611
+ // Day Mode & WiFi
612
+ const modeStr = formatDayMode(status.dayMode)
613
+ const wifiStr = status.wifiStrength != null ? `${status.wifiStrength} dBm` : '-- dBm'
614
+ view.modeText.setContent(`${modeStr} | 📶 ${wifiStr}`)
615
+
616
+ // Temperature & Nightlight
617
+ let sensorDisplay = ''
618
+ if (capabilities.hasTemperatureSensor && status.temperatureCelsius != null) {
619
+ sensorDisplay = `🌡️ ${status.temperatureCelsius}°C`
620
+ }
621
+ if (capabilities.hasColoredNightlight) {
622
+ const nightlightStr = formatNightlight(status.nightlightMode)
623
+ if (sensorDisplay) {
624
+ sensorDisplay += ` | ${nightlightStr}`
625
+ } else {
626
+ sensorDisplay = nightlightStr
627
+ }
628
+ }
629
+ if (sensorDisplay) {
630
+ view.tempText.setContent(sensorDisplay)
631
+ view.tempText.show()
632
+ } else {
633
+ view.tempText.hide()
634
+ }
635
+
636
+ // Playback
637
+ const playIcon = getPlaybackIcon(playback.playbackStatus, playback.cardId)
638
+ view.playbackHeader.setContent(`{bold}${playIcon} ${playback.playbackStatus || 'Stopped'}{/bold}`)
639
+
640
+ const trackTitle = playback.trackTitle || '--'
641
+ view.trackText.setContent(` Track: ${trackTitle.substring(0, 40)}`)
642
+
643
+ const chapterTitle = playback.chapterTitle || '--'
644
+ view.chapterText.setContent(` Chapter: ${chapterTitle.substring(0, 38)}`)
645
+
646
+ const posStr = formatTime(playback.position)
647
+ const lenStr = formatTime(playback.trackLength)
648
+ view.progressText.setContent(` ⏱️ ${posStr} / ${lenStr}`)
649
+
650
+ const cardId = playback.cardId || '--'
651
+ const cardInsertion = formatCardInsertion(status.cardInsertionState)
652
+ view.cardText.setContent(` 🎴 Card: ${cardId.substring(0, 36)} | ${cardInsertion}`)
653
+ }
654
+
655
+ /**
656
+ * Log to detail view
657
+ * @param {DetailView} view
658
+ * @param {string} message
659
+ * @param {string} [type]
660
+ */
661
+ function logToDetail (view, message, type = 'info') {
662
+ const timestamp = new Date().toLocaleTimeString()
663
+ let prefix = ''
664
+ switch (type) {
665
+ case 'status': prefix = '[STATUS]'; break
666
+ case 'config': prefix = '[CONFIG]'; break
667
+ case 'playback': prefix = '[PLAY]'; break
668
+ case 'online': prefix = '[ONLINE]'; break
669
+ case 'offline': prefix = '[OFFLINE]'; break
670
+ case 'mqtt': prefix = '[MQTT]'; break
671
+ case 'error': prefix = '[ERROR]'; break
672
+ default: prefix = '[INFO]'
673
+ }
674
+
675
+ // Add new line
676
+ view.logLines.push(`[${timestamp}] ${prefix} ${message}`)
677
+
678
+ // Calculate visible lines (box height minus border)
679
+ const visibleLines = (view.logBox.height || 10) - 2
680
+
681
+ // Keep only the last N lines
682
+ while (view.logLines.length > visibleLines) {
683
+ view.logLines.shift()
684
+ }
685
+
686
+ // Update content
687
+ view.logBox.setContent(view.logLines.join('\n'))
688
+ }
689
+
690
+ // ============================================================================
691
+ // Main Application
692
+ // ============================================================================
693
+
694
+ async function main () {
695
+ // Create Screen first for loading display
696
+ const screen = new Screen({
697
+ smartCSR: true,
698
+ title: 'Yoto Device Monitor',
699
+ fullUnicode: true
700
+ })
701
+
702
+ // Show loading indicator
703
+ const loadingBox = new Box({
704
+ parent: screen,
705
+ top: 'center',
706
+ left: 'center',
707
+ width: 44,
708
+ height: 5,
709
+ border: { type: 'line' },
710
+ tags: true,
711
+ content: '{center}🔄 Connecting to Yoto API...{/center}',
712
+ valign: 'middle',
713
+ style: {
714
+ fg: 'white',
715
+ bg: '#1a1a2e',
716
+ border: { fg: '#4a90d9' }
717
+ }
718
+ })
719
+ screen.render()
720
+
721
+ // Create YotoAccount to manage all devices
722
+ const account = new YotoAccount({
723
+ clientOptions: {
724
+ clientId,
725
+ refreshToken,
726
+ accessToken,
727
+ onTokenRefresh: async (tokens) => {
728
+ await saveTokensToEnv(envFile, {
729
+ access_token: tokens.updatedAccessToken,
730
+ refresh_token: tokens.updatedRefreshToken,
731
+ token_type: 'Bearer',
732
+ expires_in: tokens.updatedExpiresAt - Math.floor(Date.now() / 1000)
733
+ }, tokens.clientId)
734
+ }
735
+ },
736
+ deviceOptions: {
737
+ httpPollIntervalMs: pollInterval
738
+ }
739
+ })
740
+
741
+ // Start the account (discovers and starts all devices)
742
+ loadingBox.setContent('{center}📡 Discovering devices...{/center}')
743
+ screen.render()
744
+ await account.start()
745
+
746
+ // Hide loading indicator
747
+ loadingBox.hide()
748
+ loadingBox.destroy()
749
+
750
+ // Get device models as array for UI
751
+ /** @type {YotoDeviceModel[]} */
752
+ const deviceModels = Array.from(account.devices.values())
753
+
754
+ if (deviceModels.length === 0) {
755
+ screen.destroy()
756
+ console.error('❌ No devices found')
757
+ await account.stop()
758
+ process.exit(1)
759
+ }
760
+
761
+ // ============================================================================
762
+ // State
763
+ // ============================================================================
764
+
765
+ /** @type {'overview' | 'detail'} */
766
+ let currentView = 'overview'
767
+ let selectedIndex = 0
768
+ let columns = Math.max(1, Math.floor((screen.width || 80) / 30))
769
+
770
+ // ============================================================================
771
+ // Overview View
772
+ // ============================================================================
773
+
774
+ const overviewContainer = new Box({
775
+ parent: screen,
776
+ top: 0,
777
+ left: 0,
778
+ width: '100%',
779
+ height: '100%-1',
780
+ style: { fg: 'white', bg: 'black' }
781
+ })
782
+
783
+ /* eslint-disable-next-line no-new */
784
+ new Box({
785
+ parent: overviewContainer,
786
+ top: 0,
787
+ left: 'center',
788
+ width: 30,
789
+ height: 1,
790
+ content: '{center}{bold}Yoto Devices{/bold}{/center}',
791
+ tags: true,
792
+ style: { fg: '#4a90d9', bg: 'black' }
793
+ })
794
+
795
+ /** @type {DeviceCard[]} */
796
+ const deviceCards = deviceModels.map((model, index) => {
797
+ const row = Math.floor(index / columns)
798
+ const col = index % columns
799
+ return createDeviceCard(overviewContainer, model, index, row, col, index === selectedIndex)
800
+ })
801
+
802
+ // ============================================================================
803
+ // Detail View
804
+ // ============================================================================
805
+
806
+ const detailView = createDetailView(screen)
807
+
808
+ // ============================================================================
809
+ // Footer
810
+ // ============================================================================
811
+
812
+ const overviewFooter = new Box({
813
+ parent: screen,
814
+ bottom: 0,
815
+ left: 0,
816
+ width: '100%',
817
+ height: 1,
818
+ content: ' {bold}↑↓←→{/bold} Navigate | {bold}Enter{/bold} Details | {bold}q{/bold} Quit',
819
+ tags: true,
820
+ style: { fg: 'white', bg: '#333333' }
821
+ })
822
+
823
+ const detailFooter = new Box({
824
+ parent: screen,
825
+ bottom: 0,
826
+ left: 0,
827
+ width: '100%',
828
+ height: 1,
829
+ hidden: true,
830
+ content: ' {bold}Esc/b{/bold} Back | {bold}r{/bold} Refresh | {bold}c{/bold} Clear Log | {bold}q{/bold} Quit',
831
+ tags: true,
832
+ style: { fg: 'white', bg: '#333333' }
833
+ })
834
+
835
+ // ============================================================================
836
+ // View Management
837
+ // ============================================================================
838
+
839
+ function showOverview () {
840
+ currentView = 'overview'
841
+ overviewContainer.show()
842
+ overviewFooter.show()
843
+ detailView.container.hide()
844
+ detailFooter.hide()
845
+ updateOverviewDisplay()
846
+ screen.render()
847
+ }
848
+
849
+ /**
850
+ * @param {number} index
851
+ */
852
+ function showDetail (index) {
853
+ currentView = 'detail'
854
+ const model = deviceModels[index]
855
+ if (!model) return
856
+ setDetailViewModel(detailView, model)
857
+ overviewContainer.hide()
858
+ overviewFooter.hide()
859
+ detailView.container.show()
860
+ detailFooter.show()
861
+ updateDetailView(detailView)
862
+ logToDetail(detailView, `Viewing ${model.device.name}`, 'info')
863
+ screen.render()
864
+ }
865
+
866
+ function updateOverviewDisplay () {
867
+ deviceCards.forEach((card, index) => {
868
+ updateDeviceCard(card, index === selectedIndex)
869
+ })
870
+ }
871
+
872
+ /**
873
+ * Reposition cards based on current screen width
874
+ */
875
+ function repositionCards () {
876
+ const cardWidth = 28
877
+ const cardHeight = 8
878
+ columns = Math.max(1, Math.floor((screen.width || 80) / 30))
879
+
880
+ deviceCards.forEach((card, index) => {
881
+ const row = Math.floor(index / columns)
882
+ const col = index % columns
883
+ card.box.top = 1 + row * (cardHeight + 1)
884
+ card.box.left = 1 + col * (cardWidth + 1)
885
+ })
886
+ }
887
+
888
+ // Handle terminal resize
889
+ screen.on('resize', () => {
890
+ repositionCards()
891
+ screen.render()
892
+ })
893
+
894
+ /**
895
+ * @param {'up' | 'down' | 'left' | 'right'} direction
896
+ */
897
+ function navigateSelection (direction) {
898
+ const oldIndex = selectedIndex
899
+
900
+ switch (direction) {
901
+ case 'up': {
902
+ const newIndex = selectedIndex - columns
903
+ if (newIndex >= 0) selectedIndex = newIndex
904
+ break
905
+ }
906
+ case 'down': {
907
+ const newIndex = selectedIndex + columns
908
+ if (newIndex < deviceModels.length) selectedIndex = newIndex
909
+ break
910
+ }
911
+ case 'left':
912
+ if (selectedIndex > 0) selectedIndex--
913
+ break
914
+ case 'right':
915
+ if (selectedIndex < deviceModels.length - 1) selectedIndex++
916
+ break
917
+ }
918
+
919
+ if (oldIndex !== selectedIndex) {
920
+ updateOverviewDisplay()
921
+ screen.render()
922
+ }
923
+ }
924
+
925
+ // ============================================================================
926
+ // Event Handlers - Subscribe directly to device model events
927
+ // ============================================================================
928
+
929
+ deviceModels.forEach((model, index) => {
930
+ const card = deviceCards[index]
931
+ if (!card) return
932
+
933
+ model.on('statusUpdate', (_status, source, changedFields) => {
934
+ updateDeviceCard(card, index === selectedIndex)
935
+
936
+ if (currentView === 'detail' && detailView.model === model) {
937
+ if (changedFields && changedFields.size > 0) {
938
+ const fields = Array.from(changedFields).join(', ')
939
+ logToDetail(detailView, `Status via ${source}: ${fields}`, 'status')
940
+ }
941
+ updateDetailView(detailView)
942
+ }
943
+ screen.render()
944
+ })
945
+
946
+ model.on('configUpdate', (_config, changedFields) => {
947
+ updateDeviceCard(card, index === selectedIndex)
948
+
949
+ if (currentView === 'detail' && detailView.model === model) {
950
+ if (changedFields && changedFields.size > 0) {
951
+ const fields = Array.from(changedFields).join(', ')
952
+ logToDetail(detailView, `Config: ${fields}`, 'config')
953
+ }
954
+ }
955
+ screen.render()
956
+ })
957
+
958
+ model.on('playbackUpdate', (_playback, changedFields) => {
959
+ updateDeviceCard(card, index === selectedIndex)
960
+
961
+ if (currentView === 'detail' && detailView.model === model) {
962
+ if (changedFields && changedFields.size > 0) {
963
+ const fields = Array.from(changedFields).join(', ')
964
+ logToDetail(detailView, `Playback: ${fields}`, 'playback')
965
+ }
966
+ updateDetailView(detailView)
967
+ }
968
+ screen.render()
969
+ })
970
+
971
+ model.on('online', (metadata) => {
972
+ updateDeviceCard(card, index === selectedIndex)
973
+
974
+ if (currentView === 'detail' && detailView.model === model) {
975
+ logToDetail(detailView, `Device online (${metadata.reason})`, 'online')
976
+ updateDetailView(detailView)
977
+ }
978
+ screen.render()
979
+ })
980
+
981
+ model.on('offline', (metadata) => {
982
+ updateDeviceCard(card, index === selectedIndex)
983
+
984
+ if (currentView === 'detail' && detailView.model === model) {
985
+ logToDetail(detailView, `Device offline (${metadata.reason})`, 'offline')
986
+ updateDetailView(detailView)
987
+ }
988
+ screen.render()
989
+ })
990
+
991
+ model.on('mqttConnected', () => {
992
+ updateDeviceCard(card, index === selectedIndex)
993
+
994
+ if (currentView === 'detail' && detailView.model === model) {
995
+ logToDetail(detailView, 'MQTT connected', 'mqtt')
996
+ updateDetailView(detailView)
997
+ }
998
+ screen.render()
999
+ })
1000
+
1001
+ model.on('mqttDisconnected', () => {
1002
+ updateDeviceCard(card, index === selectedIndex)
1003
+
1004
+ if (currentView === 'detail' && detailView.model === model) {
1005
+ logToDetail(detailView, 'MQTT disconnected', 'mqtt')
1006
+ updateDetailView(detailView)
1007
+ }
1008
+ screen.render()
1009
+ })
1010
+
1011
+ model.on('error', (error) => {
1012
+ if (currentView === 'detail' && detailView.model === model) {
1013
+ logToDetail(detailView, `Error: ${error.message}`, 'error')
1014
+ }
1015
+ screen.render()
1016
+ })
1017
+ })
1018
+
1019
+ // ============================================================================
1020
+ // Keyboard Handlers
1021
+ // ============================================================================
1022
+
1023
+ screen.key(['q', 'C-c'], async () => {
1024
+ // Always quit
1025
+ try {
1026
+ await account.stop()
1027
+ } catch {
1028
+ // Ignore shutdown errors
1029
+ }
1030
+ screen.destroy()
1031
+ process.exit(0)
1032
+ })
1033
+
1034
+ screen.key(['escape', 'b'], () => {
1035
+ if (currentView === 'detail') {
1036
+ showOverview()
1037
+ }
1038
+ })
1039
+
1040
+ screen.key(['enter'], () => {
1041
+ if (currentView === 'overview') {
1042
+ showDetail(selectedIndex)
1043
+ }
1044
+ })
1045
+
1046
+ screen.key(['up', 'k'], () => {
1047
+ if (currentView === 'overview') {
1048
+ navigateSelection('up')
1049
+ }
1050
+ })
1051
+
1052
+ screen.key(['down', 'j'], () => {
1053
+ if (currentView === 'overview') {
1054
+ navigateSelection('down')
1055
+ }
1056
+ })
1057
+
1058
+ screen.key(['left', 'h'], () => {
1059
+ if (currentView === 'overview') {
1060
+ navigateSelection('left')
1061
+ }
1062
+ })
1063
+
1064
+ screen.key(['right', 'l'], () => {
1065
+ if (currentView === 'overview') {
1066
+ navigateSelection('right')
1067
+ }
1068
+ })
1069
+
1070
+ screen.key(['r'], async () => {
1071
+ if (currentView === 'detail' && detailView.model) {
1072
+ logToDetail(detailView, 'Refreshing config...', 'info')
1073
+ try {
1074
+ await detailView.model.refreshConfig()
1075
+ logToDetail(detailView, 'Config refreshed', 'info')
1076
+ updateDetailView(detailView)
1077
+ } catch (err) {
1078
+ const error = /** @type {Error} */ (err)
1079
+ logToDetail(detailView, `Refresh failed: ${error.message}`, 'error')
1080
+ }
1081
+ screen.render()
1082
+ }
1083
+ })
1084
+
1085
+ screen.key(['c'], () => {
1086
+ if (currentView === 'detail') {
1087
+ detailView.logBox.setContent('')
1088
+ logToDetail(detailView, 'Log cleared', 'info')
1089
+ screen.render()
1090
+ }
1091
+ })
1092
+
1093
+ // ============================================================================
1094
+ // Auto-stop timer
1095
+ // ============================================================================
1096
+
1097
+ if (duration) {
1098
+ setTimeout(async () => {
1099
+ try {
1100
+ await account.stop()
1101
+ } catch {
1102
+ // Ignore shutdown errors
1103
+ }
1104
+ screen.destroy()
1105
+ process.exit(0)
1106
+ }, duration * 1000)
1107
+ }
1108
+
1109
+ // ============================================================================
1110
+ // Initial render (devices already started by account.start())
1111
+ // ============================================================================
1112
+
1113
+ updateOverviewDisplay()
1114
+ screen.render()
1115
+
1116
+ // Set focus to screen to enable keyboard input
1117
+ screen.focusPush(overviewContainer)
1118
+ }
1119
+
1120
+ // Run the main function
1121
+ await main().catch((err) => {
1122
+ handleCliError(err)
1123
+ })