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/README.md CHANGED
@@ -7,7 +7,15 @@
7
7
  [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-7fffff?style=flat&labelColor=ff80ff)](https://github.com/neostandard/neostandard)
8
8
  [![Socket Badge](https://socket.dev/api/badge/npm/package/yoto-nodejs-client)](https://socket.dev/npm/package/yoto-nodejs-client)
9
9
 
10
- A comprehensive Node.js client for the [Yoto API][yoto-api] with automatic token refresh, MQTT device communication, and full TypeScript support.
10
+ A comprehensive Node.js client for the [Yoto API][yoto-api] with automatic token refresh, MQTT device communication, stateful device management, and full TypeScript support.
11
+
12
+ **Features:**
13
+ - **YotoClient** - Low-level HTTP API client with automatic token refresh
14
+ - **YotoDeviceModel** - Stateful device client combining HTTP + MQTT for unified, real time device state
15
+ - **YotoAccount** - Multi-device account manager with automatic discovery and lifecycle management
16
+ - Full TypeScript types
17
+ - Real-time MQTT device control and monitoring
18
+ - Debugging CLI tools for authentication and data inspection
11
19
 
12
20
  <p align="center">
13
21
  <img src="yoto.png" alt="Yoto" width="200">
@@ -19,6 +27,8 @@ npm install yoto-nodejs-client
19
27
 
20
28
  ## Usage
21
29
 
30
+ ### Basic API Client
31
+
22
32
  ```js
23
33
  import { YotoClient } from 'yoto-nodejs-client'
24
34
 
@@ -30,11 +40,16 @@ const deviceCodeResponse = await YotoClient.requestDeviceCode({
30
40
  console.log(`Visit ${deviceCodeResponse.verification_uri_complete}`)
31
41
  console.log(`Enter code: ${deviceCodeResponse.user_code}`)
32
42
 
33
- // Poll for token
34
- const tokenResponse = await YotoClient.exchangeToken({
35
- grantType: 'urn:ietf:params:oauth:grant-type:device_code',
43
+ // Wait for authorization (simplest approach - handles polling automatically)
44
+ const tokenResponse = await YotoClient.waitForDeviceAuthorization({
36
45
  deviceCode: deviceCodeResponse.device_code,
37
- clientId: 'your-client-id'
46
+ clientId: 'your-client-id',
47
+ initialInterval: deviceCodeResponse.interval * 1000,
48
+ expiresIn: deviceCodeResponse.expires_in,
49
+ onPoll: (result) => {
50
+ if (result.status === 'pending') process.stdout.write('.')
51
+ if (result.status === 'slow_down') console.log('\nSlowing down...')
52
+ }
38
53
  })
39
54
 
40
55
  // Create client with automatic token refresh
@@ -45,9 +60,9 @@ const client = new YotoClient({
45
60
  onTokenRefresh: async (event) => {
46
61
  // REQUIRED: Persist tokens when they refresh
47
62
  await saveTokens({
48
- accessToken: event.accessToken,
49
- refreshToken: event.refreshToken,
50
- expiresAt: event.expiresAt
63
+ accessToken: event.updatedAccessToken,
64
+ refreshToken: event.updatedRefreshToken,
65
+ expiresAt: event.updatedExpiresAt
51
66
  })
52
67
  }
53
68
  })
@@ -84,6 +99,153 @@ await mqtt.setVolume(50)
84
99
  await mqtt.setAmbientHex('#FF0000')
85
100
  ```
86
101
 
102
+ ### YotoDeviceModel - Stateful Device Client
103
+
104
+ ```js
105
+ import { YotoClient, YotoDeviceModel } from 'yoto-nodejs-client'
106
+
107
+ // Create API client
108
+ const client = new YotoClient({
109
+ clientId: 'your-client-id',
110
+ refreshToken: 'your-refresh-token',
111
+ accessToken: 'your-access-token',
112
+ onTokenRefresh: async (event) => {
113
+ await saveTokens(event)
114
+ }
115
+ })
116
+
117
+ // Get a device
118
+ const { devices } = await client.getDevices()
119
+ const device = devices[0]
120
+
121
+ // Create stateful device client (manages HTTP + MQTT state)
122
+ const deviceClient = new YotoDeviceModel(client, device, {
123
+ httpPollIntervalMs: 600000 // Background polling every 10 minutes
124
+ })
125
+
126
+ // Listen for status updates (from MQTT or HTTP)
127
+ deviceClient.on('statusUpdate', (status, source, changedFields) => {
128
+ console.log(`Battery: ${status.batteryLevelPercentage}% (via ${source})`)
129
+ console.log(`Temperature: ${status.temperatureCelsius}°C`)
130
+ console.log(`Online: ${status.isOnline}`)
131
+ console.log('Changed fields:', changedFields)
132
+ })
133
+
134
+ // Listen for config changes
135
+ deviceClient.on('configUpdate', (config, changedFields) => {
136
+ console.log('Config updated:', config.maxVolumeLimit)
137
+ console.log('Changed fields:', changedFields)
138
+ })
139
+
140
+ // Listen for playback events
141
+ deviceClient.on('playbackUpdate', (playback, changedFields) => {
142
+ console.log(`Playing: ${playback.trackTitle}`)
143
+ console.log(`Position: ${playback.position}/${playback.trackLength}s`)
144
+ console.log('Changed fields:', changedFields)
145
+ })
146
+
147
+ // Listen for online/offline events
148
+ deviceClient.on('online', (metadata) => {
149
+ if (metadata.reason === 'startup') {
150
+ console.log(`Device powered on (uptime: ${metadata.upTime}s)`)
151
+ } else {
152
+ console.log('Device came online')
153
+ }
154
+ })
155
+
156
+ deviceClient.on('offline', (metadata) => {
157
+ if (metadata.reason === 'shutdown') {
158
+ console.log(`Device shut down: ${metadata.shutDownReason}`)
159
+ }
160
+ })
161
+
162
+ // Start the device client (connects MQTT, starts background polling)
163
+ await deviceClient.start()
164
+
165
+ // Access current state
166
+ console.log('Current status:', deviceClient.status)
167
+ console.log('Current config:', deviceClient.config)
168
+ console.log('Current playback:', deviceClient.playback)
169
+ console.log('Device capabilities:', deviceClient.capabilities)
170
+
171
+ // Control the device
172
+ await deviceClient.updateConfig({ maxVolumeLimit: '14' })
173
+ await deviceClient.sendCommand({ volume: 50 })
174
+
175
+ // Stop when done
176
+ await deviceClient.stop()
177
+ ```
178
+
179
+ ### Account Manager (Multiple Devices)
180
+
181
+ ```js
182
+ import { YotoAccount } from 'yoto-nodejs-client'
183
+
184
+ // Create account manager
185
+ const account = new YotoAccount({
186
+ clientOptions: {
187
+ clientId: 'your-client-id',
188
+ refreshToken: 'your-refresh-token',
189
+ accessToken: 'your-access-token',
190
+ onTokenRefresh: async (event) => {
191
+ await saveTokens(event)
192
+ }
193
+ },
194
+ deviceOptions: {
195
+ httpPollIntervalMs: 600000 // Applied to all devices
196
+ }
197
+ })
198
+
199
+ // Listen for account events
200
+ account.on('started', (metadata) => {
201
+ console.log(`Managing ${metadata.deviceCount} devices`)
202
+ })
203
+
204
+
205
+
206
+ // Listen for device-specific events from individual devices
207
+ account.on('deviceAdded', (deviceId, deviceModel) => {
208
+ // Attach event listeners to each device as it's added
209
+ deviceModel.on('statusUpdate', (status, source) => {
210
+ console.log(`${deviceId} battery: ${status.batteryLevelPercentage}%`)
211
+ })
212
+
213
+ deviceModel.on('online', (metadata) => {
214
+ console.log(`${deviceId} came online`)
215
+ })
216
+
217
+ deviceModel.on('offline', (metadata) => {
218
+ console.log(`${deviceId} went offline`)
219
+ })
220
+ })
221
+
222
+ // Unified error handling
223
+ account.on('error', (error, context) => {
224
+ console.error(`Error in ${context.source}:`, error.message)
225
+ if (context.deviceId) {
226
+ console.error(`Device: ${context.deviceId}`)
227
+ }
228
+ })
229
+
230
+ // Start managing all devices
231
+ await account.start()
232
+
233
+ // Access individual devices
234
+ const device = account.getDevice('abc123')
235
+ console.log('Device status:', device.status)
236
+ console.log('Device config:', device.config)
237
+
238
+ // Get all devices
239
+ const allDevices = account.devices // Map<deviceId, YotoDeviceModel>
240
+ console.log('Total devices:', allDevices.size)
241
+
242
+ // Refresh device list (add new, remove missing)
243
+ await account.refreshDevices()
244
+
245
+ // Stop all devices
246
+ await account.stop()
247
+ ```
248
+
87
249
  ## API
88
250
 
89
251
  ### Authentication
@@ -122,9 +284,11 @@ Exchange authorization code, refresh token, or device code for access tokens.
122
284
  See [Yoto API: Token Exchange][api-token]
123
285
 
124
286
  ```js
287
+ import { YotoClient, DEVICE_CODE_GRANT_TYPE } from 'yoto-nodejs-client'
288
+
125
289
  // Exchange device code
126
290
  const tokens = await YotoClient.exchangeToken({
127
- grantType: 'urn:ietf:params:oauth:grant-type:device_code',
291
+ grantType: DEVICE_CODE_GRANT_TYPE,
128
292
  deviceCode: response.device_code,
129
293
  clientId: 'your-client-id'
130
294
  })
@@ -137,6 +301,103 @@ const refreshed = await YotoClient.exchangeToken({
137
301
  })
138
302
  ```
139
303
 
304
+ #### `YotoClient.waitForDeviceAuthorization({ deviceCode, clientId, [initialInterval], [expiresIn], [onPoll] })`
305
+
306
+ Wait for device authorization to complete with automatic polling. This is the **simplest approach** - just call it and await the result. It handles all polling logic internally including interval adjustments and timeout detection.
307
+
308
+ Designed for CLI usage where you want to block until authorization completes. For UI implementations with custom progress feedback, use `pollForDeviceToken()` directly.
309
+
310
+ - **deviceCode** - Device code from `requestDeviceCode()`
311
+ - **clientId** - OAuth client ID
312
+ - **initialInterval** - Initial polling interval in milliseconds (default: 5000)
313
+ - **expiresIn** - Seconds until device code expires (for timeout detection)
314
+ - **audience** - Audience for the token (default: 'https://api.yotoplay.com')
315
+ - **onPoll** - Optional callback invoked after each poll attempt with the poll result
316
+
317
+ Returns a promise that resolves to `YotoTokenResponse` on successful authorization.
318
+
319
+ Throws `YotoAPIError` for unrecoverable errors (expired_token, access_denied, invalid_grant) or `Error` if device code expires.
320
+
321
+ See [Yoto API: Token Exchange][api-token]
322
+
323
+ ```js
324
+ import { YotoClient } from 'yoto-nodejs-client'
325
+
326
+ // Simplest approach - just wait for tokens
327
+ const deviceAuth = await YotoClient.requestDeviceCode({
328
+ clientId: 'your-client-id'
329
+ })
330
+
331
+ console.log(`Visit: ${deviceAuth.verification_uri_complete}`)
332
+ console.log(`Code: ${deviceAuth.user_code}`)
333
+
334
+ // This blocks until authorization completes (or fails)
335
+ const tokens = await YotoClient.waitForDeviceAuthorization({
336
+ deviceCode: deviceAuth.device_code,
337
+ clientId: 'your-client-id',
338
+ initialInterval: deviceAuth.interval * 1000,
339
+ expiresIn: deviceAuth.expires_in,
340
+ onPoll: (result) => {
341
+ if (result.status === 'pending') process.stdout.write('.')
342
+ if (result.status === 'slow_down') console.log('\nSlowing down...')
343
+ }
344
+ })
345
+
346
+ console.log('Got tokens:', tokens)
347
+ ```
348
+
349
+ #### `YotoClient.pollForDeviceToken({ deviceCode, clientId, [currentInterval], [audience] })`
350
+
351
+ Poll for device authorization completion with automatic error handling (single poll attempt). This is a lower-level method that gives you control over the polling loop. Use `waitForDeviceAuthorization()` for a simpler approach.
352
+
353
+ Non-blocking - returns immediately with poll result. Suitable for:
354
+ - Manual polling loops in CLI applications
355
+ - Server-side endpoints that poll on behalf of clients (e.g., Homebridge UI server)
356
+ - Custom UI implementations with specific polling behavior
357
+
358
+ - **deviceCode** - Device code from `requestDeviceCode()`
359
+ - **clientId** - OAuth client ID
360
+ - **currentInterval** - Current polling interval in milliseconds (default: 5000)
361
+ - **audience** - Audience for the token (default: 'https://api.yotoplay.com')
362
+
363
+ Returns a promise that resolves to one of:
364
+ - `{ status: 'success', tokens: YotoTokenResponse }` - Authorization successful
365
+ - `{ status: 'pending', interval: number }` - Still waiting for user authorization
366
+ - `{ status: 'slow_down', interval: number }` - Polling too fast, use new interval
367
+
368
+ Throws `YotoAPIError` for unrecoverable errors (expired_token, access_denied, invalid_grant, etc).
369
+
370
+ See [Yoto API: Token Exchange][api-token]
371
+
372
+ ```js
373
+ import { YotoClient } from 'yoto-nodejs-client'
374
+
375
+ // Manual polling loop with full control
376
+ const deviceAuth = await YotoClient.requestDeviceCode({
377
+ clientId: 'your-client-id'
378
+ })
379
+
380
+ let interval = deviceAuth.interval * 1000
381
+
382
+ while (true) {
383
+ const result = await YotoClient.pollForDeviceToken({
384
+ deviceCode: deviceAuth.device_code,
385
+ clientId: 'your-client-id',
386
+ currentInterval: interval
387
+ })
388
+
389
+ if (result.status === 'success') {
390
+ console.log('Tokens:', result.tokens)
391
+ break
392
+ } else if (result.status === 'slow_down') {
393
+ interval = result.interval
394
+ }
395
+
396
+ // Sleep
397
+ await new Promise(resolve => setTimeout(resolve, interval))
398
+ }
399
+ ```
400
+
140
401
  #### `YotoClient.getAuthorizeUrl({ clientId, redirectUri, responseType, state, ...params })`
141
402
 
142
403
  Get authorization URL for browser-based OAuth flow. Returns a URL string to redirect users to.
@@ -156,9 +417,9 @@ Create a new Yoto API client with automatic token refresh.
156
417
  - **bufferSeconds** - Seconds before expiration to refresh (default: 30)
157
418
  - **userAgent** - Optional user agent string to identify your application
158
419
  - **defaultRequestOptions** - Optional default undici request options (dispatcher, timeouts, etc.) applied to all requests
159
- - **onRefreshStart** - Optional callback when refresh starts
160
- - **onRefreshError** - Optional callback for transient refresh errors
161
- - **onInvalid** - Optional callback when refresh token is permanently invalid
420
+ - **onRefreshStart** - Optional callback when refresh starts. Console.logs by default.
421
+ - **onRefreshError** - Optional callback for transient refresh errors. Console.error's by default.
422
+ - **onInvalid** - Optional callback when refresh token is permanently invalid. Console.errors by default.
162
423
 
163
424
  ```js
164
425
  const client = new YotoClient({
@@ -171,16 +432,16 @@ const client = new YotoClient({
171
432
  headersTimeout: 10000, // 10 second header timeout
172
433
  // dispatcher, signal, etc.
173
434
  },
174
- onTokenRefresh: async ({ accessToken, refreshToken, expiresAt }) => {
435
+ onTokenRefresh: async ({ updatedAccessToken, updatedRefreshToken, updatedExpiresAt }) => {
175
436
  // Save to database, file, etc.
176
- await db.saveTokens({ accessToken, refreshToken, expiresAt })
437
+ await db.saveTokens({ accessToken: updatedAccessToken, refreshToken: updatedRefreshToken, expiresAt: updatedExpiresAt })
177
438
  }
178
439
  })
179
440
  ```
180
441
 
181
442
  #### Request Options
182
443
 
183
- All API methods accept an optional `requestOptions` parameter that allows you to override the default undici request options for individual requests. This is useful for setting custom timeouts, using a specific dispatcher, or aborting requests.
444
+ All API methods accept an optional [undici][undici] `requestOptions` parameter that allows you to override the default undici request options for individual requests. This is useful for setting custom timeouts, using a specific dispatcher, or aborting requests.
184
445
 
185
446
  ```js
186
447
  // Override timeout for a specific request
@@ -650,49 +911,280 @@ The MQTT client emits three types of messages:
650
911
  - `await mqtt.stopCard()` - Stop playback
651
912
  - `await mqtt.reboot()` - Reboot device
652
913
 
914
+ ### YotoDeviceModel - Stateful Device Client
915
+
916
+ #### `new YotoDeviceModel(client, device, [options])`
917
+
918
+ Create a stateful device client that manages device state primarily from MQTT with HTTP background sync.
919
+
920
+ **Philosophy:**
921
+ - MQTT is the primary source for all real-time status updates
922
+ - MQTT connection is always maintained and handles its own reconnection
923
+ - Device online/offline state is tracked by MQTT activity and explicit shutdown messages
924
+ - HTTP background polling runs every 10 minutes to sync config+status regardless of online state
925
+ - HTTP status updates emit offline events if device state changes to offline
926
+
927
+ **Parameters:**
928
+ - `client` - YotoClient instance
929
+ - `device` - Device object from `getDevices()`
930
+ - `options.httpPollIntervalMs` - Background HTTP polling interval (default: 600000ms / 10 minutes)
931
+ - `options.mqttOptions` - MQTT.js client options to pass through
932
+
933
+ **Lifecycle:**
934
+ - `await deviceClient.start()` - Start device client (connects MQTT, starts polling)
935
+ - `await deviceClient.stop()` - Stop device client (disconnects MQTT, stops polling)
936
+ - `await deviceClient.restart()` - Restart device client
937
+
938
+ **State Accessors:**
939
+ - `deviceClient.device` - Device information
940
+ - `deviceClient.status` - Current device status (normalized from HTTP/MQTT)
941
+ - `deviceClient.config` - Device configuration
942
+ - `deviceClient.shortcuts` - Button shortcuts
943
+ - `deviceClient.playback` - Current playback state
944
+ - `deviceClient.capabilities` - Hardware capabilities (sensors, nightlight support, etc.)
945
+ - `deviceClient.nightlight` - Nightlight info: { value, name, supported }
946
+ - `deviceClient.initialized` - Whether device has been initialized
947
+ - `deviceClient.running` - Whether device client is currently running
948
+ - `deviceClient.mqttConnected` - MQTT connection status
949
+ - `deviceClient.deviceOnline` - Device online status
950
+ - `deviceClient.mqttClient` - Underlying MQTT client instance (or null)
951
+
952
+ **Device Control:**
953
+ - `await deviceClient.refreshConfig()` - Refresh config from HTTP API
954
+ - `await deviceClient.updateConfig(configUpdate)` - Update device configuration
955
+ - `await deviceClient.sendCommand(command)` - Send device command via HTTP
956
+
957
+ **Events:**
958
+ - `started(metadata)` - Device client started, passes metadata object with device, config, shortcuts, status, playback, initialized, running
959
+ - `stopped()` - Device client stopped
960
+ - `statusUpdate(status, source, changedFields)` - Status changed, passes (status, source, changedFields). Source is 'http', 'mqtt', or 'mqtt-event'
961
+ - `configUpdate(config, changedFields)` - Configuration changed, passes (config, changedFields)
962
+ - `playbackUpdate(playback, changedFields)` - Playback state changed, passes (playback, changedFields)
963
+ - `online(metadata)` - Device came online, passes metadata with reason and optional upTime
964
+ - `offline(metadata)` - Device went offline, passes metadata with reason and optional shutDownReason or timeSinceLastSeen
965
+ - `mqttConnected()` - MQTT client connected
966
+ - `mqttDisconnected()` - MQTT client disconnected
967
+ - `error(error)` - Error occurred, passes error
968
+
969
+ **Static Properties & Methods:**
970
+ - `YotoDeviceModel.NIGHTLIGHT_COLORS` - Map of nightlight color hex codes to official color names
971
+ - `YotoDeviceModel.getNightlightColorName(colorValue)` - Get official color name for a nightlight value
972
+
973
+ ```js
974
+ import { YotoClient, YotoDeviceModel } from 'yoto-nodejs-client'
975
+
976
+ const client = new YotoClient({ /* ... */ })
977
+ const { devices } = await client.getDevices()
978
+
979
+ const deviceClient = new YotoDeviceModel(client, devices[0], {
980
+ httpPollIntervalMs: 300000 // Poll every 5 minutes
981
+ })
982
+
983
+ deviceClient.on('statusUpdate', (status, source, changedFields) => {
984
+ console.log(`Battery: ${status.batteryLevelPercentage}% (${source})`)
985
+ console.log('Changed fields:', changedFields)
986
+ })
987
+
988
+ deviceClient.on('online', (metadata) => {
989
+ console.log('Device online:', metadata.reason)
990
+ })
991
+
992
+ await deviceClient.start()
993
+
994
+ // Access state
995
+ console.log('Temperature:', deviceClient.status.temperatureCelsius)
996
+ console.log('Has temp sensor:', deviceClient.capabilities.hasTemperatureSensor)
997
+ console.log('Nightlight:', deviceClient.nightlight) // { value, name, supported }
998
+
999
+ // Use static nightlight utilities
1000
+ console.log('Available colors:', YotoDeviceModel.NIGHTLIGHT_COLORS)
1001
+ console.log('Color name:', YotoDeviceModel.getNightlightColorName('0x643600'))
1002
+
1003
+ // Control device
1004
+ await deviceClient.updateConfig({ maxVolumeLimit: '14' })
1005
+
1006
+ await deviceClient.stop()
1007
+ ```
1008
+
1009
+ ### Account Manager
1010
+
1011
+ #### `new YotoAccount({ clientOptions, deviceOptions })`
1012
+
1013
+ Create an account manager that automatically discovers and manages all devices for a Yoto account.
1014
+
1015
+ **Parameters:**
1016
+ - `clientOptions` - YotoClient constructor options (clientId, refreshToken, accessToken, onTokenRefresh, etc.)
1017
+ - `deviceOptions` - YotoDeviceModel options applied to all devices (httpPollIntervalMs, mqttOptions)
1018
+
1019
+ **Lifecycle:**
1020
+ - `await account.start()` - Start account (creates client, discovers devices, starts all device clients)
1021
+ - `await account.stop()` - Stop account (stops all device clients gracefully)
1022
+ - `await account.restart()` - Restart account
1023
+ - `await account.refreshDevices()` - Refresh device list (add new, remove missing)
1024
+
1025
+ **State Accessors:**
1026
+ - `account.client` - Underlying YotoClient instance
1027
+ - `account.devices` - Map of all device models (Map<deviceId, YotoDeviceModel>)
1028
+ - `account.getDevice(deviceId)` - Get specific device model
1029
+ - `account.getDeviceIds()` - Get array of all device IDs
1030
+ - `account.running` - Whether account is currently running
1031
+ - `account.initialized` - Whether account has been initialized
1032
+
1033
+ **Events:**
1034
+ - `started(metadata)` - Account started (metadata: { deviceCount, devices })
1035
+ - `stopped()` - Account stopped
1036
+ - `deviceAdded(deviceId, deviceModel)` - Device was added
1037
+ - `deviceRemoved(deviceId)` - Device was removed
1038
+ - `error(error, context)` - Error occurred (context: { source, deviceId, operation })
1039
+
1040
+ **Note:** To listen to individual device events (statusUpdate, configUpdate, playbackUpdate, online, offline, mqttConnected, mqttDisconnected, etc.), access the device models directly via `account.devices` or `account.getDevice(deviceId)` and attach listeners to them.
1041
+
1042
+ ```js
1043
+ import { YotoAccount } from 'yoto-nodejs-client'
1044
+
1045
+ const account = new YotoAccount({
1046
+ clientOptions: {
1047
+ clientId: 'your-client-id',
1048
+ refreshToken: 'your-refresh-token',
1049
+ accessToken: 'your-access-token',
1050
+ onTokenRefresh: async (event) => {
1051
+ await saveTokens(event)
1052
+ }
1053
+ },
1054
+ deviceOptions: {
1055
+ httpPollIntervalMs: 600000 // 10 minutes
1056
+ }
1057
+ })
1058
+
1059
+ // Account-level error handling
1060
+ account.on('error', (error, context) => {
1061
+ console.error(`Error in ${context.source}:`, error.message)
1062
+ })
1063
+
1064
+ // Listen to device added events
1065
+ account.on('deviceAdded', (deviceId, deviceModel) => {
1066
+ console.log(`Device ${deviceId} added`)
1067
+
1068
+ // Attach listeners to individual devices
1069
+ deviceModel.on('statusUpdate', (status, source) => {
1070
+ console.log(`${deviceId} battery: ${status.batteryLevelPercentage}%`)
1071
+ })
1072
+
1073
+ deviceModel.on('online', (metadata) => {
1074
+ console.log(`${deviceId} came online (${metadata.reason})`)
1075
+ })
1076
+
1077
+ deviceModel.on('offline', (metadata) => {
1078
+ console.log(`${deviceId} went offline (${metadata.reason})`)
1079
+ })
1080
+ })
1081
+
1082
+ await account.start()
1083
+
1084
+ // Access individual devices and attach listeners
1085
+ const device = account.getDevice('abc123')
1086
+ console.log('Device battery:', device.status.batteryLevelPercentage)
1087
+
1088
+ // Listen to specific device events
1089
+ device.on('playbackUpdate', (playback) => {
1090
+ console.log('Now playing:', playback.currentCardTitle)
1091
+ })
1092
+
1093
+ // Iterate all devices and attach listeners
1094
+ for (const [deviceId, deviceModel] of account.devices) {
1095
+ console.log(`${deviceId}: ${deviceModel.status.batteryLevelPercentage}%`)
1096
+
1097
+ deviceModel.on('configUpdate', (config) => {
1098
+ console.log(`${deviceId} config updated:`, config.name)
1099
+ })
1100
+ }
1101
+
1102
+ await account.stop()
1103
+ ```
1104
+
653
1105
  ## CLI Tools
654
1106
 
655
- The library includes CLI tools for authentication and data inspection:
1107
+ The library includes CLI tools for authentication and data inspection. After installing the package, these commands are available globally:
656
1108
 
657
1109
  ### Authentication
658
1110
 
659
1111
  ```bash
660
1112
  # Get initial tokens (device flow)
661
- node bin/auth.js --output .env
1113
+ yoto-auth --output .env
1114
+ # or: node bin/auth.js --output .env
662
1115
 
663
- # Refresh tokens
664
- node bin/refresh-token.js
1116
+ # Refresh existing tokens
1117
+ yoto-refresh-token
1118
+ # or: node bin/refresh-token.js
665
1119
 
666
- # Show token info
667
- node bin/token-info.js
1120
+ # Show token info and inspect JWT contents
1121
+ yoto-token-info
1122
+ # or: node bin/token-info.js
668
1123
  ```
669
1124
 
670
- ### Data Inspection
1125
+ ### Devices
671
1126
 
672
1127
  ```bash
673
1128
  # List all devices
674
- node bin/devices.js
1129
+ yoto-devices
1130
+ # or: node bin/devices.js
675
1131
 
676
1132
  # Get device details with config
677
- node bin/devices.js --device-id abc123
1133
+ yoto-devices --device-id abc123
678
1134
 
679
1135
  # Get device status only
680
- node bin/devices.js --device-id abc123 --status
1136
+ yoto-devices --device-id abc123 --status
681
1137
 
682
1138
  # Connect to MQTT and listen for messages
683
- node bin/devices.js --device-id abc123 --mqtt
1139
+ yoto-devices --device-id abc123 --mqtt
684
1140
 
685
1141
  # Sample MQTT messages for 10 seconds
686
- node bin/devices.js --device-id abc123 --mqtt --mqtt-stop-after-seconds 10
1142
+ yoto-devices --device-id abc123 --mqtt --mqtt-timeout 10
1143
+
1144
+ # Use YotoDeviceModel to monitor device (HTTP + MQTT)
1145
+ yoto-device-model --device-id abc123
687
1146
 
1147
+ # Interactive TUI for device control (Prototype/WIP/Unpublished)
1148
+ yoto-device-tui --device-id abc123
1149
+ ```
1150
+
1151
+ ### Content
1152
+
1153
+ ```bash
688
1154
  # List all MYO content
689
- node bin/content.js
1155
+ yoto-content
1156
+ # or: node bin/content.js
690
1157
 
691
1158
  # Get specific card details
692
- node bin/content.js --card-id 5WsQg
1159
+ yoto-content --card-id 5WsQg
693
1160
 
694
1161
  # Get card with playable URLs
695
- node bin/content.js --card-id 5WsQg --playable
1162
+ yoto-content --card-id 5WsQg --playable
1163
+ ```
1164
+
1165
+ ### Family Library Groups
1166
+
1167
+ ```bash
1168
+ # List all family library groups
1169
+ yoto-groups
1170
+ # or: node bin/groups.js
1171
+
1172
+ # Get specific group details
1173
+ yoto-groups --group-id abc123
1174
+ ```
1175
+
1176
+ ### Icons
1177
+
1178
+ ```bash
1179
+ # List both public and user icons
1180
+ yoto-icons
1181
+ # or: node bin/icons.js
1182
+
1183
+ # List only public Yoto icons
1184
+ yoto-icons --public
1185
+
1186
+ # List only user custom icons
1187
+ yoto-icons --user
696
1188
  ```
697
1189
 
698
1190
  ## See also
@@ -734,3 +1226,4 @@ MIT
734
1226
  [api-upload-icon]: https://yoto.dev/api/uploadcustomicon/
735
1227
  [api-audio-upload]: https://yoto.dev/api/getanuploadurl/
736
1228
  [api-cover-image]: https://yoto.dev/api/uploadcoverimage/
1229
+ [undici]: https://undici.nodejs.org/#/docs/api/Client.md