yoto-nodejs-client 0.0.2 → 0.0.4

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 +8 -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 +21 -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
package/bin/auth.js CHANGED
@@ -9,7 +9,7 @@ import { parseArgs } from 'node:util'
9
9
  import { YotoClient } from '../index.js'
10
10
  import { pkg } from '../lib/pkg.cjs'
11
11
  import { DEFAULT_CLIENT_ID } from '../lib/api-endpoints/constants.js'
12
- import { sleep, saveTokensToEnv } from './lib/token-helpers.js'
12
+ import { saveTokensToEnv } from './lib/token-helpers.js'
13
13
  import {
14
14
  getCommonOptions,
15
15
  handleCliError,
@@ -72,56 +72,46 @@ async function main () {
72
72
  console.log(` Code expires in: ${expiresInMinutes} minute(s)`)
73
73
  console.log(` Polling every: ${deviceAuth.interval} second(s)\n`)
74
74
 
75
- // Step 3: Poll for authorization
75
+ // Step 3: Wait for authorization (with automatic polling)
76
76
  console.log('Waiting for authorization...')
77
77
 
78
- let pollInterval = deviceAuth.interval * 1000
79
- const startTime = Date.now()
80
- const expiresAt = startTime + (deviceAuth.expires_in * 1000)
78
+ try {
79
+ const tokens = await YotoClient.waitForDeviceAuthorization({
80
+ deviceCode: deviceAuth.device_code,
81
+ clientId,
82
+ initialInterval: deviceAuth.interval * 1000,
83
+ expiresIn: deviceAuth.expires_in,
84
+ onPoll: (result) => {
85
+ if (result.status === 'pending') {
86
+ process.stdout.write('.')
87
+ } else if (result.status === 'slow_down') {
88
+ console.log(`\n⚠️ Slowing down polling to ${result.interval / 1000}s...`)
89
+ }
90
+ }
91
+ })
92
+
93
+ // Success! Save tokens to .env file
94
+ console.log('\n✅ Authorization successful!\n')
95
+
96
+ await saveTokensToEnv(outputFile, tokens, clientId)
81
97
 
82
- while (true) {
83
- // Check if we've exceeded the expiration time
84
- if (Date.now() >= expiresAt) {
98
+ console.log(`✨ Tokens saved to ${outputFile}`)
99
+ console.log('\nYou can now use these environment variables:')
100
+ console.log(' - YOTO_ACCESS_TOKEN')
101
+ console.log(' - YOTO_REFRESH_TOKEN')
102
+ console.log(' - YOTO_CLIENT_ID')
103
+
104
+ process.exit(0)
105
+ } catch (err) {
106
+ const error = /** @type {any} */ (err)
107
+ if (error.message === 'Device code has expired') {
85
108
  console.error('\n❌ Device code has expired. Please run the command again.')
86
109
  process.exit(1)
87
- }
88
-
89
- try {
90
- const tokens = await YotoClient.exchangeToken({
91
- grantType: 'urn:ietf:params:oauth:grant-type:device_code',
92
- deviceCode: deviceAuth.device_code,
93
- clientId
94
- })
95
-
96
- // Success! Save tokens to .env file
97
- console.log('\n✅ Authorization successful!\n')
98
-
99
- await saveTokensToEnv(outputFile, tokens, clientId)
100
-
101
- console.log(`✨ Tokens saved to ${outputFile}`)
102
- console.log('\nYou can now use these environment variables:')
103
- console.log(' - YOTO_ACCESS_TOKEN')
104
- console.log(' - YOTO_REFRESH_TOKEN')
105
- console.log(' - YOTO_CLIENT_ID')
106
-
107
- process.exit(0)
108
- } catch (err) {
109
- const error = /** @type {any} */ (err)
110
- if (error.body?.error === 'authorization_pending') {
111
- process.stdout.write('.')
112
- await sleep(pollInterval)
113
- continue
114
- } else if (error.body?.error === 'slow_down') {
115
- pollInterval += 5000
116
- console.log(`\n⚠️ Slowing down polling to ${pollInterval / 1000}s...`)
117
- await sleep(pollInterval)
118
- continue
119
- } else if (error.body?.error === 'expired_token') {
120
- console.error('\n❌ Device code has expired. Please run the command again.')
121
- process.exit(1)
122
- } else {
123
- handleCliError(error)
124
- }
110
+ } else if (error.body?.error === 'expired_token') {
111
+ console.error('\n❌ Device code has expired. Please run the command again.')
112
+ process.exit(1)
113
+ } else {
114
+ handleCliError(error)
125
115
  }
126
116
  }
127
117
  }
package/bin/content.js CHANGED
File without changes
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=device-model.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"device-model.d.ts","sourceRoot":"","sources":["device-model.js"],"names":[],"mappings":""}
@@ -0,0 +1,360 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @import { ArgscloptsParseArgsOptionsConfig } from 'argsclopts'
5
+ */
6
+
7
+ import { printHelpText } from 'argsclopts'
8
+ import { parseArgs } from 'node:util'
9
+ import { pkg } from '../lib/pkg.cjs'
10
+ import {
11
+ getCommonOptions,
12
+ loadTokensFromEnv,
13
+ createYotoClient,
14
+ handleCliError,
15
+ printHeader
16
+ } from './lib/cli-helpers.js'
17
+ import { YotoDeviceModel } from '../lib/yoto-device.js'
18
+
19
+ /** @type {ArgscloptsParseArgsOptionsConfig} */
20
+ const options = {
21
+ ...getCommonOptions(),
22
+ 'poll-interval': {
23
+ type: 'string',
24
+ short: 'p',
25
+ help: 'HTTP polling interval in milliseconds (default: 600000 / 10 minutes)'
26
+ },
27
+ duration: {
28
+ type: 'string',
29
+ short: 't',
30
+ help: 'How long to run in seconds (default: run indefinitely)'
31
+ }
32
+ }
33
+
34
+ const args = parseArgs({ options, strict: false, allowPositionals: true })
35
+
36
+ if (args.values['help']) {
37
+ await printHelpText({
38
+ options,
39
+ name: 'yoto-device-model',
40
+ version: pkg.version,
41
+ exampleFn: ({ name }) => ` Yoto Device Model tester - Stateful device client with HTTP + MQTT
42
+
43
+ Examples:
44
+ ${name} abc123 # Start device model (runs indefinitely)
45
+ ${name} abc123 --duration 30 # Run for 30 seconds
46
+ ${name} abc123 --poll-interval 300000 # Poll every 5 minutes
47
+ `
48
+ })
49
+ process.exit(0)
50
+ }
51
+
52
+ // Load tokens from environment
53
+ const { clientId, refreshToken, accessToken, envFile } = loadTokensFromEnv(args)
54
+
55
+ const deviceId = args.positionals[0]
56
+ const pollInterval = args.values['poll-interval'] ? Number(args.values['poll-interval']) : 600000
57
+ const duration = args.values['duration'] ? Number(args.values['duration']) : null
58
+
59
+ if (!deviceId) {
60
+ console.error('❌ deviceId is required as first positional argument')
61
+ console.error('Usage: yoto-device-model <deviceId> [options]')
62
+ console.error('Example: yoto-device-model abc123 --duration 30')
63
+ process.exit(1)
64
+ }
65
+
66
+ async function main () {
67
+ printHeader('Yoto Device Model Tester')
68
+
69
+ try {
70
+ // Create API client
71
+ console.log('\n📡 Creating YotoClient...')
72
+ const client = createYotoClient({
73
+ clientId,
74
+ refreshToken,
75
+ accessToken,
76
+ outputFile: envFile
77
+ })
78
+
79
+ // Get device info
80
+ console.log(`\n🔍 Fetching device info for: ${deviceId}`)
81
+ const { devices } = await client.getDevices()
82
+ const device = devices.find(d => d.deviceId === deviceId)
83
+
84
+ if (!device) {
85
+ console.error(`❌ Device with ID '${deviceId}' not found`)
86
+ process.exit(1)
87
+ }
88
+
89
+ console.log(`✅ Found device: ${device.name} (${device.deviceType})`)
90
+
91
+ // Create device model
92
+ console.log('\n🎛️ Creating YotoDeviceModel...')
93
+ const deviceModel = new YotoDeviceModel(client, device, {
94
+ httpPollIntervalMs: pollInterval
95
+ })
96
+
97
+ console.log(` HTTP polling interval: ${pollInterval}ms (${pollInterval / 1000}s)`)
98
+
99
+ // Setup event handlers with timer-based display logic
100
+ console.log('\n📡 Setting up event handlers...')
101
+
102
+ // Timer state for each event type
103
+ let lastStatusFullDisplay = 0
104
+ let lastConfigFullDisplay = 0
105
+ let lastPlaybackFullDisplay = 0
106
+ const FULL_DISPLAY_INTERVAL_MS = 30000 // 30 seconds
107
+
108
+ deviceModel.on('started', () => {
109
+ console.log('✅ Device model started')
110
+ })
111
+
112
+ deviceModel.on('stopped', () => {
113
+ console.log('🛑 Device model stopped')
114
+ })
115
+
116
+ deviceModel.on('statusUpdate', (status, source, changedFields) => {
117
+ const timestamp = new Date().toISOString()
118
+ const capabilities = deviceModel.capabilities
119
+ const now = Date.now()
120
+ const showFullState = (now - lastStatusFullDisplay) >= FULL_DISPLAY_INTERVAL_MS
121
+
122
+ if (showFullState) {
123
+ // Show full state
124
+ console.log(`\n📊 STATUS UPDATE [${timestamp}] via ${source}: (FULL STATE)`)
125
+ console.log(` Battery: ${status.batteryLevelPercentage}%`)
126
+ console.log(` Charging: ${status.isCharging}`)
127
+ console.log(` Online: ${status.isOnline}`)
128
+ console.log(` Max Volume: ${status.maxVolume}/16`)
129
+ console.log(` Volume: ${status.volume}/16`)
130
+ if (capabilities.hasTemperatureSensor) {
131
+ console.log(` Temperature: ${status.temperatureCelsius}°C`)
132
+ }
133
+ if (capabilities.hasAmbientLightSensor) {
134
+ console.log(` Ambient Light: ${status.ambientLightSensorReading}`)
135
+ }
136
+ console.log(` WiFi: ${status.wifiStrength} dBm`)
137
+ console.log(` Active Card: ${status.activeCardId || 'none'}`)
138
+ console.log(` Power Source: ${status.powerSource}`)
139
+ console.log(` Day Mode: ${status.dayMode}`)
140
+ console.log(` Card Insertion: ${status.cardInsertionState}`)
141
+ if (capabilities.hasColoredNightlight) {
142
+ const nightlight = deviceModel.nightlight
143
+ console.log(` Nightlight: ${nightlight.name} (${nightlight.value})`)
144
+ }
145
+ console.log(` Firmware: ${status.firmwareVersion}`)
146
+ console.log(` Uptime: ${status.uptime}s`)
147
+ console.log(` Updated At: ${status.updatedAt}`)
148
+ lastStatusFullDisplay = now
149
+ } else if (changedFields && changedFields.size > 0) {
150
+ // Show only changed fields
151
+ console.log(`\n📊 STATUS UPDATE [${timestamp}] via ${source}: (${changedFields.size} change${changedFields.size === 1 ? '' : 's'})`)
152
+ for (const field of changedFields) {
153
+ let value = status[field]
154
+ if (field === 'volume') {
155
+ value = `${value}/16 (max: ${status.maxVolume}/16)`
156
+ } else if (field === 'maxVolume') {
157
+ value = `${value}/16`
158
+ } else if (field === 'temperatureCelsius') {
159
+ value = `${value}°C`
160
+ } else if (field === 'wifiStrength') {
161
+ value = `${value} dBm`
162
+ } else if (field === 'batteryLevelPercentage') {
163
+ value = `${value}%`
164
+ } else if (field === 'uptime') {
165
+ value = `${value}s`
166
+ } else if (field === 'nightlightMode' && typeof value === 'string') {
167
+ const colorName = YotoDeviceModel.getNightlightColorName(value)
168
+ value = `${colorName} (${value})`
169
+ }
170
+ console.log(` ${field}: ${value}`)
171
+ }
172
+ } else {
173
+ // No changes (shouldn't happen, but handle it)
174
+ console.log(`\n📊 STATUS UPDATE [${timestamp}] via ${source}: (no changes)`)
175
+ }
176
+ })
177
+
178
+ deviceModel.on('configUpdate', (config, changedFields) => {
179
+ const timestamp = new Date().toISOString()
180
+ const now = Date.now()
181
+ const showFullState = (now - lastConfigFullDisplay) >= FULL_DISPLAY_INTERVAL_MS
182
+
183
+ if (showFullState) {
184
+ // Show full config state
185
+ console.log(`\n⚙️ CONFIG UPDATE [${timestamp}]: (FULL STATE)`)
186
+ console.log(` Max Volume Limit: ${config.maxVolumeLimit}`)
187
+ console.log(` Day Time: ${config.dayTime}`)
188
+ console.log(` Night Time: ${config.nightTime}`)
189
+ console.log(` Timezone: ${config.timezone}`)
190
+ console.log(` Repeat All: ${config.repeatAll}`)
191
+ console.log(` Bluetooth Enabled: ${config.bluetoothEnabled}`)
192
+ console.log(` BT Headphones Enabled: ${config.btHeadphonesEnabled}`)
193
+ console.log(` Clock Face: ${config.clockFace}`)
194
+ console.log(` Day Display Brightness: ${config.dayDisplayBrightness}`)
195
+ console.log(` Night Display Brightness: ${config.nightDisplayBrightness}`)
196
+ console.log(` Shutdown Timeout: ${config.shutdownTimeout}`)
197
+ console.log(` Volume Level: ${config.volumeLevel}`)
198
+ console.log(` Ambient Colour: ${config.ambientColour}`)
199
+ console.log(` Night Ambient Colour: ${config.nightAmbientColour}`)
200
+ lastConfigFullDisplay = now
201
+ } else if (changedFields && changedFields.size > 0) {
202
+ // Show only changed fields
203
+ console.log(`\n⚙️ CONFIG UPDATE [${timestamp}]: (${changedFields.size} change${changedFields.size === 1 ? '' : 's'})`)
204
+ for (const field of changedFields) {
205
+ console.log(` ${field}: ${config[field]}`)
206
+ }
207
+ } else {
208
+ console.log(`\n⚙️ CONFIG UPDATE [${timestamp}]: (no changes)`)
209
+ }
210
+ })
211
+
212
+ deviceModel.on('playbackUpdate', (playback, changedFields) => {
213
+ const timestamp = new Date().toISOString()
214
+ const now = Date.now()
215
+ const showFullState = (now - lastPlaybackFullDisplay) >= FULL_DISPLAY_INTERVAL_MS
216
+
217
+ // Get playback icon based on state
218
+ let playbackIcon = '⏏️' // Default: ejected/no card
219
+ const hasCard = playback.cardId !== null
220
+ if (hasCard || playback.playbackStatus === 'playing' || playback.playbackStatus === 'paused') {
221
+ if (playback.playbackStatus === 'playing') {
222
+ playbackIcon = '▶️'
223
+ } else if (playback.playbackStatus === 'paused') {
224
+ playbackIcon = '⏸️'
225
+ } else {
226
+ playbackIcon = '⏹️' // Stopped but card present
227
+ }
228
+ }
229
+
230
+ if (showFullState) {
231
+ // Show full playback state
232
+ console.log(`\n${playbackIcon} PLAYBACK UPDATE [${timestamp}]: (FULL STATE)`)
233
+ console.log(` Status: ${playback.playbackStatus}`)
234
+ console.log(` Track: ${playback.trackTitle}`)
235
+ console.log(` Track Key: ${playback.trackKey}`)
236
+ console.log(` Chapter: ${playback.chapterTitle}`)
237
+ console.log(` Chapter Key: ${playback.chapterKey}`)
238
+ console.log(` Position: ${playback.position}/${playback.trackLength}s`)
239
+ console.log(` Card ID: ${playback.cardId}`)
240
+ console.log(` Source: ${playback.source}`)
241
+ console.log(` Streaming: ${playback.streaming}`)
242
+ console.log(` Sleep Timer Active: ${playback.sleepTimerActive}`)
243
+ console.log(` Sleep Timer Seconds: ${playback.sleepTimerSeconds}`)
244
+ console.log(` Updated At: ${playback.updatedAt}`)
245
+ lastPlaybackFullDisplay = now
246
+ } else if (changedFields && changedFields.size > 0) {
247
+ // Show only changed fields
248
+ console.log(`\n${playbackIcon} PLAYBACK UPDATE [${timestamp}]: (${changedFields.size} change${changedFields.size === 1 ? '' : 's'})`)
249
+ for (const field of changedFields) {
250
+ let value = playback[field]
251
+ if (field === 'position' || field === 'trackLength') {
252
+ // Show position/trackLength together if either changed
253
+ value = `${playback.position}/${playback.trackLength}s`
254
+ console.log(` position/trackLength: ${value}`)
255
+ // Skip if we already printed this combo
256
+ if (field === 'trackLength') continue
257
+ } else {
258
+ console.log(` ${field}: ${value}`)
259
+ }
260
+ }
261
+ } else {
262
+ console.log(`\n${playbackIcon} PLAYBACK UPDATE [${timestamp}]: (no changes)`)
263
+ }
264
+ })
265
+
266
+ deviceModel.on('online', (metadata) => {
267
+ const timestamp = new Date().toISOString()
268
+ console.log(`\n🟢 DEVICE ONLINE [${timestamp}]:`)
269
+ console.log(` Reason: ${metadata.reason}`)
270
+ if (metadata.upTime !== undefined) {
271
+ console.log(` Uptime: ${metadata.upTime}s`)
272
+ }
273
+ })
274
+
275
+ deviceModel.on('offline', (metadata) => {
276
+ const timestamp = new Date().toISOString()
277
+ console.log(`\n🔴 DEVICE OFFLINE [${timestamp}]:`)
278
+ console.log(` Reason: ${metadata.reason}`)
279
+ if (metadata.shutDownReason) {
280
+ console.log(` Shutdown Reason: ${metadata.shutDownReason}`)
281
+ }
282
+ if (metadata.timeSinceLastSeen !== undefined) {
283
+ console.log(` Time Since Last Seen: ${metadata.timeSinceLastSeen}ms`)
284
+ }
285
+ })
286
+
287
+ deviceModel.on('mqttConnected', () => {
288
+ const timestamp = new Date().toISOString()
289
+ console.log(`\n🔌 MQTT CONNECTED [${timestamp}]`)
290
+ })
291
+
292
+ deviceModel.on('mqttDisconnected', () => {
293
+ const timestamp = new Date().toISOString()
294
+ console.log(`\n🔌 MQTT DISCONNECTED [${timestamp}]`)
295
+ })
296
+
297
+ deviceModel.on('error', (error) => {
298
+ const timestamp = new Date().toISOString()
299
+ console.error(`\n⚠️ ERROR [${timestamp}]:`)
300
+ console.error(` ${error.message}`)
301
+ })
302
+
303
+ // Handle graceful shutdown
304
+ const cleanup = async () => {
305
+ console.log('\n\n🛑 Shutting down...')
306
+ try {
307
+ await deviceModel.stop()
308
+ console.log('✅ Device model stopped cleanly')
309
+ process.exit(0)
310
+ } catch (err) {
311
+ console.error('❌ Error during shutdown:', err)
312
+ process.exit(1)
313
+ }
314
+ }
315
+
316
+ process.on('SIGINT', cleanup)
317
+ process.on('SIGTERM', cleanup)
318
+
319
+ // Start device model
320
+ console.log('\n🚀 Starting device model...')
321
+ await deviceModel.start()
322
+
323
+ console.log('\n✅ Device model is running!')
324
+ console.log('\n📊 Current State:')
325
+ console.log(` Device: ${deviceModel.device.name}`)
326
+ console.log(` Type: ${deviceModel.device.deviceType}`)
327
+ console.log(` Online: ${deviceModel.deviceOnline}`)
328
+ console.log(` MQTT Connected: ${deviceModel.mqttConnected}`)
329
+ console.log(` Initialized: ${deviceModel.initialized}`)
330
+ console.log(` Running: ${deviceModel.running}`)
331
+
332
+ console.log('\n🔧 Capabilities:')
333
+ console.log(` Temperature Sensor: ${deviceModel.capabilities.hasTemperatureSensor}`)
334
+ console.log(` Ambient Light Sensor: ${deviceModel.capabilities.hasAmbientLightSensor}`)
335
+ console.log(` Colored Nightlight: ${deviceModel.capabilities.hasColoredNightlight}`)
336
+ console.log(` Supported: ${deviceModel.capabilities.supported}`)
337
+
338
+ console.log('\n👂 Listening for events... (Ctrl+C to stop)')
339
+
340
+ if (duration) {
341
+ console.log(`⏱️ Will auto-stop after ${duration} seconds\n`)
342
+ setTimeout(async () => {
343
+ console.log(`\n⏰ ${duration} seconds elapsed, stopping...`)
344
+ await cleanup()
345
+ }, duration * 1000)
346
+ } else {
347
+ console.log(' (Running indefinitely)\n')
348
+ }
349
+
350
+ // Keep process alive (unless duration is set)
351
+ if (!duration) {
352
+ await new Promise(() => {}) // Keep alive indefinitely
353
+ }
354
+ } catch (error) {
355
+ handleCliError(error)
356
+ }
357
+ }
358
+
359
+ // Run the main function
360
+ await main().catch(handleCliError)
@@ -0,0 +1,125 @@
1
+ # Device TUI - Next Steps
2
+
3
+ ## Known Limitations to Address
4
+
5
+ ### P1 - High Priority
6
+
7
+ - [ ] **Grid doesn't resize on terminal resize**
8
+ - `columns` is calculated once at startup
9
+ - Should listen for `screen.on('resize')` and reflow cards
10
+ - May need to recreate card widgets or just reposition them
11
+
12
+ - [ ] **No scroll in overview**
13
+ - If more devices than fit on screen, they overflow/clip
14
+ - Consider using a scrollable container or List widget for overview
15
+ - Alternative: pagination with page indicators
16
+
17
+ ### P2 - Medium Priority
18
+
19
+ - [ ] **No device hot-add/remove**
20
+ - If devices are added/removed from account, TUI doesn't update
21
+ - Could poll `account.refreshDevices()` periodically
22
+ - Or add a manual refresh key (`R` to refresh device list?)
23
+
24
+ - [ ] **Fixed card dimensions**
25
+ - Cards are hardcoded at 28x8 chars
26
+ - May clip content on very small terminals
27
+ - Consider adaptive sizing based on terminal dimensions
28
+
29
+ ## Feature Ideas
30
+
31
+ ### Device Interaction (not just monitoring)
32
+
33
+ - [ ] **Volume control**
34
+ - `+`/`-` keys to adjust volume in detail view
35
+ - Show visual feedback when changing
36
+
37
+ - [ ] **Playback controls**
38
+ - Space to play/pause
39
+ - `n`/`p` for next/previous track
40
+ - `s` to stop playback
41
+
42
+ - [ ] **Sleep timer**
43
+ - Set sleep timer from TUI
44
+ - Show countdown in detail view
45
+
46
+ - [ ] **Day/Night mode toggle**
47
+ - Quick toggle between day/night mode
48
+
49
+ ### Display Enhancements
50
+
51
+ - [ ] **Progress bar for playback**
52
+ - Visual progress bar instead of just time display
53
+ - `[████████░░░░░░░░] 3:45 / 8:20`
54
+
55
+ - [ ] **Card content info**
56
+ - Show card title/artwork info if available
57
+ - Maybe fetch card metadata from API
58
+
59
+ - [ ] **Alarms display**
60
+ - Show configured alarms in detail view
61
+ - Ability to enable/disable alarms
62
+
63
+ - [ ] **Config editing**
64
+ - Edit device config values from detail view
65
+ - Form-based UI for settings
66
+
67
+ ### UX Improvements
68
+
69
+ - [ ] **Loading spinner during initialization**
70
+ - Replace console.log with proper TUI loading screen
71
+ - Show progress as each device connects
72
+
73
+ - [ ] **Connection status in footer**
74
+ - Show overall connection health
75
+ - "3/3 devices online" or similar
76
+
77
+ - [ ] **Notification/toast system**
78
+ - Brief popup notifications for events
79
+ - "Device X went offline" toast
80
+
81
+ - [ ] **Help overlay**
82
+ - `?` key to show all keyboard shortcuts
83
+ - Dismissable overlay with command reference
84
+
85
+ - [ ] **Search/filter devices**
86
+ - `/` to search by name
87
+ - Filter by online/offline status
88
+
89
+ ## Technical Improvements
90
+
91
+ - [ ] **Extract TUI components**
92
+ - Move `createDeviceCard`, `createDetailView` to separate files
93
+ - Create reusable component pattern
94
+
95
+ - [ ] **Add YotoAccount device event forwarding**
96
+ - Update `YotoAccount` to forward all device events (statusUpdate, configUpdate, etc.)
97
+ - Would simplify TUI event subscription code
98
+
99
+ - [ ] **Configuration file**
100
+ - Support config file for TUI preferences
101
+ - Colors, refresh intervals, default view, etc.
102
+
103
+ - [ ] **Logging to file**
104
+ - Option to log events to file for debugging
105
+ - `--log-file` CLI option
106
+
107
+ ## Refactoring
108
+
109
+ - [ ] **Consider blessed-contrib widgets**
110
+ - Gauge widget for battery
111
+ - Sparkline for historical data
112
+ - LCD-style numbers for volume
113
+
114
+ - [ ] **State management**
115
+ - Currently state is scattered (selectedIndex, currentView, etc.)
116
+ - Consider a simple state object with update functions
117
+
118
+ ## Testing Notes
119
+
120
+ - Test with 1 device
121
+ - Test with many devices (5+) to verify grid layout
122
+ - Test device going offline/online during session
123
+ - Test token refresh during long session
124
+ - Test on different terminal sizes
125
+ - Test vim key navigation (hjkl)
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ export type DeviceCard = {
3
+ box: Box;
4
+ nameText: Text;
5
+ statusText: Text;
6
+ batteryText: Text;
7
+ playbackText: Text;
8
+ model: YotoDeviceModel;
9
+ index: number;
10
+ };
11
+ export type DetailView = {
12
+ container: Box;
13
+ deviceBox: Box;
14
+ connectionText: Text;
15
+ batteryText: Text;
16
+ volumeText: Text;
17
+ modeText: Text;
18
+ tempText: Text;
19
+ playbackHeader: Text;
20
+ trackText: Text;
21
+ chapterText: Text;
22
+ progressText: Text;
23
+ cardText: Text;
24
+ logBox: Box;
25
+ logLines: string[];
26
+ model: YotoDeviceModel | null;
27
+ };
28
+ import { Box } from '@unblessed/node';
29
+ import { Text } from '@unblessed/node';
30
+ import type { YotoDeviceModel } from '../lib/yoto-device.js';
31
+ //# sourceMappingURL=device-tui.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"device-tui.d.ts","sourceRoot":"","sources":["device-tui.js"],"names":[],"mappings":";;SAuOc,GAAG;cACH,IAAI;gBACJ,IAAI;iBACJ,IAAI;kBACJ,IAAI;WACJ,eAAe;WACf,MAAM;;;eAmHN,GAAG;eACH,GAAG;oBACH,IAAI;iBACJ,IAAI;gBACJ,IAAI;cACJ,IAAI;cACJ,IAAI;oBACJ,IAAI;eACJ,IAAI;iBACJ,IAAI;kBACJ,IAAI;cACJ,IAAI;YACJ,GAAG;cACH,MAAM,EAAE;WACR,eAAe,GAAG,IAAI;;oBArW6B,iBAAiB;qBAAjB,iBAAiB;qCAH9C,uBAAuB"}