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.
- package/README.md +523 -30
- package/bin/auth.js +36 -46
- package/bin/content.js +0 -0
- package/bin/device-model.d.ts +3 -0
- package/bin/device-model.d.ts.map +1 -0
- package/bin/device-model.js +360 -0
- package/bin/device-tui.TODO.md +125 -0
- package/bin/device-tui.d.ts +31 -0
- package/bin/device-tui.d.ts.map +1 -0
- package/bin/device-tui.js +1123 -0
- package/bin/devices.js +166 -28
- package/bin/groups.js +0 -0
- package/bin/icons.js +0 -0
- package/bin/lib/cli-helpers.d.ts +1 -1
- package/bin/lib/cli-helpers.d.ts.map +1 -1
- package/bin/lib/cli-helpers.js +5 -5
- package/bin/refresh-token.js +6 -6
- package/bin/token-info.js +3 -3
- package/index.d.ts +4 -585
- package/index.d.ts.map +1 -1
- package/index.js +11 -689
- package/lib/api-client.d.ts +576 -0
- package/lib/api-client.d.ts.map +1 -0
- package/lib/api-client.js +681 -0
- package/lib/api-endpoints/auth.d.ts +199 -8
- package/lib/api-endpoints/auth.d.ts.map +1 -1
- package/lib/api-endpoints/auth.js +224 -7
- package/lib/api-endpoints/auth.test.js +54 -2
- package/lib/api-endpoints/constants.d.ts +14 -8
- package/lib/api-endpoints/constants.d.ts.map +1 -1
- package/lib/api-endpoints/constants.js +17 -10
- package/lib/api-endpoints/content.test.js +1 -1
- package/lib/api-endpoints/devices.d.ts +405 -117
- package/lib/api-endpoints/devices.d.ts.map +1 -1
- package/lib/api-endpoints/devices.js +114 -52
- package/lib/api-endpoints/devices.test.js +1 -1
- package/lib/api-endpoints/{test-helpers.d.ts → endpoint-test-helpers.d.ts} +1 -1
- package/lib/api-endpoints/endpoint-test-helpers.d.ts.map +1 -0
- package/lib/api-endpoints/family-library-groups.test.js +1 -1
- package/lib/api-endpoints/family.test.js +1 -1
- package/lib/api-endpoints/icons.test.js +1 -1
- package/lib/helpers/power-state.d.ts +53 -0
- package/lib/helpers/power-state.d.ts.map +1 -0
- package/lib/helpers/power-state.js +73 -0
- package/lib/helpers/power-state.test.js +100 -0
- package/lib/helpers/temperature.d.ts +24 -0
- package/lib/helpers/temperature.d.ts.map +1 -0
- package/lib/helpers/temperature.js +61 -0
- package/lib/helpers/temperature.test.js +58 -0
- package/lib/helpers/typed-keys.d.ts +7 -0
- package/lib/helpers/typed-keys.d.ts.map +1 -0
- package/lib/helpers/typed-keys.js +8 -0
- package/lib/mqtt/client.d.ts +348 -22
- package/lib/mqtt/client.d.ts.map +1 -1
- package/lib/mqtt/client.js +213 -31
- package/lib/mqtt/factory.d.ts +22 -4
- package/lib/mqtt/factory.d.ts.map +1 -1
- package/lib/mqtt/factory.js +27 -5
- package/lib/mqtt/mqtt.test.js +85 -28
- package/lib/mqtt/topics.d.ts +41 -13
- package/lib/mqtt/topics.d.ts.map +1 -1
- package/lib/mqtt/topics.js +54 -20
- package/lib/pkg.d.cts +9 -0
- package/lib/token.d.ts +21 -6
- package/lib/token.d.ts.map +1 -1
- package/lib/token.js +30 -23
- package/lib/yoto-account.d.ts +163 -0
- package/lib/yoto-account.d.ts.map +1 -0
- package/lib/yoto-account.js +340 -0
- package/lib/yoto-device.d.ts +656 -0
- package/lib/yoto-device.d.ts.map +1 -0
- package/lib/yoto-device.js +2850 -0
- package/package.json +22 -15
- package/lib/api-endpoints/test-helpers.d.ts.map +0 -1
- /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
|
+
})
|