yoto-nodejs-client 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +523 -30
  2. package/bin/auth.js +36 -46
  3. package/bin/content.js +0 -0
  4. package/bin/device-model.d.ts +3 -0
  5. package/bin/device-model.d.ts.map +1 -0
  6. package/bin/device-model.js +360 -0
  7. package/bin/device-tui.TODO.md +125 -0
  8. package/bin/device-tui.d.ts +31 -0
  9. package/bin/device-tui.d.ts.map +1 -0
  10. package/bin/device-tui.js +1123 -0
  11. package/bin/devices.js +166 -28
  12. package/bin/groups.js +0 -0
  13. package/bin/icons.js +0 -0
  14. package/bin/lib/cli-helpers.d.ts +1 -1
  15. package/bin/lib/cli-helpers.d.ts.map +1 -1
  16. package/bin/lib/cli-helpers.js +5 -5
  17. package/bin/refresh-token.js +6 -6
  18. package/bin/token-info.js +3 -3
  19. package/index.d.ts +4 -585
  20. package/index.d.ts.map +1 -1
  21. package/index.js +11 -689
  22. package/lib/api-client.d.ts +576 -0
  23. package/lib/api-client.d.ts.map +1 -0
  24. package/lib/api-client.js +681 -0
  25. package/lib/api-endpoints/auth.d.ts +199 -8
  26. package/lib/api-endpoints/auth.d.ts.map +1 -1
  27. package/lib/api-endpoints/auth.js +224 -7
  28. package/lib/api-endpoints/auth.test.js +54 -2
  29. package/lib/api-endpoints/constants.d.ts +14 -8
  30. package/lib/api-endpoints/constants.d.ts.map +1 -1
  31. package/lib/api-endpoints/constants.js +17 -10
  32. package/lib/api-endpoints/content.test.js +1 -1
  33. package/lib/api-endpoints/devices.d.ts +405 -117
  34. package/lib/api-endpoints/devices.d.ts.map +1 -1
  35. package/lib/api-endpoints/devices.js +114 -52
  36. package/lib/api-endpoints/devices.test.js +1 -1
  37. package/lib/api-endpoints/{test-helpers.d.ts → endpoint-test-helpers.d.ts} +1 -1
  38. package/lib/api-endpoints/endpoint-test-helpers.d.ts.map +1 -0
  39. package/lib/api-endpoints/family-library-groups.test.js +1 -1
  40. package/lib/api-endpoints/family.test.js +1 -1
  41. package/lib/api-endpoints/icons.test.js +1 -1
  42. package/lib/helpers/power-state.d.ts +53 -0
  43. package/lib/helpers/power-state.d.ts.map +1 -0
  44. package/lib/helpers/power-state.js +73 -0
  45. package/lib/helpers/power-state.test.js +100 -0
  46. package/lib/helpers/temperature.d.ts +24 -0
  47. package/lib/helpers/temperature.d.ts.map +1 -0
  48. package/lib/helpers/temperature.js +61 -0
  49. package/lib/helpers/temperature.test.js +58 -0
  50. package/lib/helpers/typed-keys.d.ts +7 -0
  51. package/lib/helpers/typed-keys.d.ts.map +1 -0
  52. package/lib/helpers/typed-keys.js +8 -0
  53. package/lib/mqtt/client.d.ts +348 -22
  54. package/lib/mqtt/client.d.ts.map +1 -1
  55. package/lib/mqtt/client.js +213 -31
  56. package/lib/mqtt/factory.d.ts +22 -4
  57. package/lib/mqtt/factory.d.ts.map +1 -1
  58. package/lib/mqtt/factory.js +27 -5
  59. package/lib/mqtt/mqtt.test.js +85 -28
  60. package/lib/mqtt/topics.d.ts +41 -13
  61. package/lib/mqtt/topics.d.ts.map +1 -1
  62. package/lib/mqtt/topics.js +54 -20
  63. package/lib/pkg.d.cts +9 -0
  64. package/lib/token.d.ts +21 -6
  65. package/lib/token.d.ts.map +1 -1
  66. package/lib/token.js +30 -23
  67. package/lib/yoto-account.d.ts +163 -0
  68. package/lib/yoto-account.d.ts.map +1 -0
  69. package/lib/yoto-account.js +340 -0
  70. package/lib/yoto-device.d.ts +656 -0
  71. package/lib/yoto-device.d.ts.map +1 -0
  72. package/lib/yoto-device.js +2850 -0
  73. package/package.json +22 -15
  74. package/lib/api-endpoints/test-helpers.d.ts.map +0 -1
  75. /package/lib/api-endpoints/{test-helpers.js → endpoint-test-helpers.js} +0 -0
@@ -20,8 +20,8 @@
20
20
  * @typedef {Object} YotoEventsMessage
21
21
  * @property {boolean} [repeatAll] - Repeat all tracks
22
22
  * @property {boolean} [streaming] - Whether streaming
23
- * @property {number} [volume] - Current volume level
24
- * @property {number} [volumeMax] - Maximum volume level
23
+ * @property {number} [volume] - Current user volume level (0-16 scale, maps to userVolumePercentage in status)
24
+ * @property {number} [volumeMax] - Maximum volume limit (0-16 scale, maps to systemVolumePercentage in status)
25
25
  * @property {boolean} [playbackWait] - Playback waiting
26
26
  * @property {boolean} [sleepTimerActive] - Sleep timer active
27
27
  * @property {number} [eventUtc] - Unix timestamp
@@ -34,37 +34,52 @@
34
34
  * @property {string} [chapterKey] - Current chapter key
35
35
  * @property {string} [trackTitle] - Current track title
36
36
  * @property {string} [trackKey] - Current track key
37
- * @property {string} [playbackStatus] - Playback status (e.g., "playing", "paused", "stopped")
37
+ * @property {'playing' | 'paused' | 'stopped' | 'loading' | string} [playbackStatus] - Playback status
38
38
  * @property {number} [sleepTimerSeconds] - Seconds remaining on sleep timer
39
39
  */
40
40
 
41
41
  /**
42
- * Status message from device
42
+ * Status message from device (MQTT /data/status)
43
+ *
44
+ * Device automatically publishes status updates every 5 minutes (matching keepalive interval).
45
+ * Can also be requested on-demand via requestStatus().
46
+ *
47
+ * Note: MQTT types differ from HTTP - uses booleans, non-nullable fields
43
48
  * @see https://yoto.dev/players-mqtt/mqtt-docs/#deviceiddatastatus
44
49
  * @typedef {Object} YotoStatusMessage
50
+ * @property {YotoMqttStatus} status - Status object
51
+ */
52
+
53
+ /**
54
+ * MQTT status payload structure (documented spec)
55
+ *
56
+ * Automatic updates (every 5 minutes): 21 fields (excludes nightlightMode and temp)
57
+ * Requested status: All 23 fields including nightlightMode and temp
58
+ *
59
+ * @typedef {Object} YotoMqttStatus
45
60
  * @property {number} statusVersion - Status message version
46
61
  * @property {string} fwVersion - Firmware version
47
62
  * @property {string} productType - Product type identifier
48
63
  * @property {number} batteryLevel - Battery level percentage
49
64
  * @property {number} als - Ambient light sensor reading
50
- * @property {number} freeDisk - Free disk space
65
+ * @property {number} freeDisk - Free disk space in bytes
51
66
  * @property {number} shutdownTimeout - Shutdown timeout in seconds
52
67
  * @property {number} dbatTimeout - DBAT timeout
53
68
  * @property {number} charging - Charging state (0 or 1)
54
- * @property {string} activeCard - Active card ID
55
- * @property {number} cardInserted - Card insertion state
69
+ * @property {string} activeCard - Active card ID or 'none'
70
+ * @property {0 | 1 | 2} cardInserted - Card insertion state (0=none, 1=physical, 2=remote)
56
71
  * @property {number} playingStatus - Playing status code
57
72
  * @property {boolean} headphones - Headphones connected
58
73
  * @property {number} dnowBrightness - Current display brightness
59
74
  * @property {number} dayBright - Day brightness setting
60
75
  * @property {number} nightBright - Night brightness setting
61
76
  * @property {boolean} bluetoothHp - Bluetooth headphones enabled
62
- * @property {number} volume - Current volume
63
- * @property {number} userVolume - User volume setting
77
+ * @property {number} volume - System/max volume level (0-100 percentage, represents 0-16 hardware scale, maps to volumeMax in events)
78
+ * @property {number} userVolume - User volume setting (0-100 percentage, represents 0-16 hardware scale, maps to volume in events)
64
79
  * @property {'12' | '24'} timeFormat - Time format preference
65
- * @property {string} nightlightMode - Nightlight mode setting
66
- * @property {string} temp - Temperature reading
67
- * @property {number} day - Day mode (0 or 1)
80
+ * @property {string} [nightlightMode] - Current nightlight color (actual hex color like '0xff5733' or 'off') - only in requested status, most accurate source
81
+ * @property {string} [temp] - Temperature reading (format varies: 'value1:value2:value3' or 'value1:notSupported') - only in requested status
82
+ * @property {-1 | 0 | 1} day - Day mode (0=night, 1=day, -1=unknown)
68
83
  */
69
84
 
70
85
  /**
@@ -85,14 +100,136 @@
85
100
 
86
101
  */
87
102
 
103
+ /**
104
+ * Legacy status message from device (MQTT /status)
105
+ *
106
+ * This is the older undocumented status topic that contains critical lifecycle information
107
+ * not available in the documented /data/status topic, including:
108
+ * - shutDown field: Indicates device power state changes ('userShutdown', 'nA', etc.)
109
+ * - Startup detection: Low upTime values, utcTime: 0 after power on
110
+ * - Full hardware diagnostics: battery voltage, memory stats, temperatures
111
+ *
112
+ * Both documented and legacy status topics are necessary for complete device monitoring.
113
+ *
114
+ * @typedef {Object} YotoStatusLegacyMessage
115
+ * @property {YotoLegacyStatus} status - Legacy status object with full hardware details
116
+ */
117
+
118
+ /**
119
+ * Legacy MQTT status payload structure (undocumented)
120
+ *
121
+ * Contains all fields from documented status plus additional lifecycle and diagnostic fields.
122
+ *
123
+ * @typedef {Object} YotoLegacyStatus
124
+ * @property {number} statusVersion - Status message version
125
+ * @property {string} fwVersion - Firmware version
126
+ * @property {string} shutDown - Power state: 'nA' = device running, any other value = shutting down/shut down (e.g., 'userShutdown') - ONLY in legacy topic
127
+ * @property {number} totalDisk - Total disk space in bytes
128
+ * @property {string} productType - Product type identifier
129
+ * @property {number} wifiStrength - WiFi signal strength in dBm
130
+ * @property {string} ssid - WiFi SSID
131
+ * @property {number} rtcResetReasonPRO - RTC reset reason (PRO)
132
+ * @property {number} rtcResetReasonAPP - RTC reset reason (APP)
133
+ * @property {number} rtcWakeupCause - RTC wakeup cause code
134
+ * @property {number} espResetReason - ESP reset reason code
135
+ * @property {string} sd_info - SD card information string
136
+ * @property {number} battery - Raw battery voltage in millivolts
137
+ * @property {string} powerCaps - Power capabilities
138
+ * @property {number} batteryLevel - Battery level percentage
139
+ * @property {number} batteryTemp - Battery temperature
140
+ * @property {string} batteryData - Battery data string (format: 'val1:val2:val3')
141
+ * @property {number} batteryLevelRaw - Raw battery level reading
142
+ * @property {number} free - Free memory in bytes
143
+ * @property {number} freeDMA - Free DMA memory in bytes
144
+ * @property {number} free32 - Free 32-bit memory in bytes
145
+ * @property {number} upTime - Device uptime in seconds (low values indicate recent startup)
146
+ * @property {number} utcTime - UTC timestamp (0 indicates fresh startup before time sync)
147
+ * @property {number} aliveTime - Total alive time in seconds
148
+ * @property {number} accelTemp - Accelerometer temperature in Celsius
149
+ * @property {string} batteryProfile - Battery profile identifier
150
+ * @property {number} freeDisk - Free disk space in bytes
151
+ * @property {number} failReason - Failure reason code
152
+ * @property {number} failData - Failure data
153
+ * @property {number} shutdownTimeout - Shutdown timeout in seconds
154
+ * @property {number} utcOffset - UTC offset in seconds
155
+ * @property {string} nfcErrs - NFC error rates (format: 'xx.xx%-xx.xx%')
156
+ * @property {number} dbatTimeout - DBAT timeout in seconds
157
+ * @property {number} charging - Charging state (0 or 1)
158
+ * @property {0 | 1 | 2 | 3} powerSrc - Power source (0=battery, 1=V2 dock, 2=USB-C, 3=Qi)
159
+ * @property {string} activeCard - Active card ID or 'none'
160
+ * @property {0 | 1 | 2} cardInserted - Card insertion state (0=none, 1=physical, 2=remote)
161
+ * @property {number} playingStatus - Playing status code
162
+ * @property {number} headphones - Headphones connected (0 or 1)
163
+ * @property {number} wifiRestarts - WiFi restart count
164
+ * @property {number} qiOtp - Qi OTP value
165
+ * @property {number} buzzErrors - Buzzer error count
166
+ * @property {number} dnowBrightness - Current display brightness
167
+ * @property {number} dayBright - Day brightness setting
168
+ * @property {number} nightBright - Night brightness setting
169
+ * @property {number} errorsLogged - Number of errors logged
170
+ * @property {number} twdt - Task watchdog timer value
171
+ * @property {number} bluetoothHp - Bluetooth headphones state (0 or 1)
172
+ * @property {string} nightlightMode - Current nightlight color (hex color like '0xff5733' or 'off')
173
+ * @property {number} bgDownload - Background download state
174
+ * @property {number} bytesPS - Bytes per second
175
+ * @property {-1 | 0 | 1} day - Day mode (0=night, 1=day, -1=unknown)
176
+ * @property {string} temp - Temperature readings (format: 'val1:val2:val3')
177
+ * @property {number} als - Ambient light sensor reading
178
+ * @property {number} volume - System/max volume level (0-100 percentage, represents 0-16 hardware scale, maps to volumeMax in events)
179
+ * @property {number} userVolume - User volume setting (0-100 percentage, represents 0-16 hardware scale, maps to volume in events)
180
+ * @property {'12' | '24'} timeFormat - Time format preference
181
+ * @property {number} chgStatLevel - Charge state level
182
+ * @property {number} missedLogs - Missed log count
183
+ * @property {number} nfcLock - NFC lock state
184
+ * @property {number} batteryFullPct - Battery full percentage threshold
185
+ */
186
+
88
187
  /**
89
188
  * MQTT connection state
90
189
  * @typedef {'disconnected' | 'connected' | 'reconnecting'} YotoMqttConnectionState
91
190
  */
92
191
 
192
+ /**
193
+ * Events message callback
194
+ * @callback EventsCallback
195
+ * @param {string} topic - Raw MQTT topic string
196
+ * @param {YotoEventsMessage} payload - Events message payload
197
+ */
198
+
199
+ /**
200
+ * Status message callback
201
+ * @callback StatusCallback
202
+ * @param {string} topic - Raw MQTT topic string
203
+ * @param {YotoStatusMessage} payload - Status message payload
204
+ */
205
+
206
+ /**
207
+ * Legacy status message callback
208
+ * @callback StatusLegacyCallback
209
+ * @param {string} topic - Raw MQTT topic string
210
+ * @param {YotoStatusLegacyMessage} payload - Legacy status message payload with lifecycle events
211
+ */
212
+
213
+ /**
214
+ * Response message callback
215
+ * @callback ResponseCallback
216
+ * @param {string} topic - Raw MQTT topic string
217
+ * @param {YotoResponseMessage} payload - Response message payload
218
+ */
219
+
220
+ /**
221
+ * Unknown message callback
222
+ * @callback UnknownCallback
223
+ * @param {string} topic - Raw MQTT topic string
224
+ * @param {any} payload - Unknown message payload
225
+ */
226
+
93
227
  import { EventEmitter } from 'events'
94
228
  import {
95
229
  getSubscriptionTopics,
230
+ getEventsTopic,
231
+ getStatusTopic,
232
+ getResponseTopic,
96
233
  parseTopic,
97
234
  getEventsRequestTopic,
98
235
  getStatusRequestTopic,
@@ -118,9 +255,14 @@ import { commands } from './commands.js'
118
255
  * Yoto MQTT Client class
119
256
  * @extends EventEmitter
120
257
  *
121
- * @fires YotoMqttClient#events
122
- * @fires YotoMqttClient#status
123
- * @fires YotoMqttClient#response
258
+ * Device automatically publishes status updates every 5 minutes when connected.
259
+ * Status can also be requested on-demand via requestStatus() and requestEvents().
260
+ *
261
+ * @fires YotoMqttClient#events - Emits (topic, payload) when device sends events
262
+ * @fires YotoMqttClient#status - Emits (topic, payload) when device sends status (automatic every 5 min, or on-demand)
263
+ * @fires YotoMqttClient#status-legacy - Emits (topic, payload) when device sends legacy status with lifecycle events
264
+ * @fires YotoMqttClient#response - Emits (topic, payload) when device responds to commands
265
+ * @fires YotoMqttClient#unknown - Emits (topic, payload) when receiving unknown message type
124
266
  * @fires YotoMqttClient#connected
125
267
  * @fires YotoMqttClient#disconnected
126
268
  * @fires YotoMqttClient#reconnecting
@@ -209,14 +351,43 @@ export class YotoMqttClient extends EventEmitter {
209
351
 
210
352
  /**
211
353
  * Subscribe to device topics
354
+ * @param {Array<'events' | 'status' | 'response'> | 'all'} [types='all'] - Topic types to subscribe to, or 'all' for all topics
212
355
  * @returns {Promise<void>}
356
+ * @example
357
+ * // Subscribe to all topics (default)
358
+ * await client.subscribe()
359
+ * await client.subscribe('all')
360
+ *
361
+ * // Subscribe to specific topics
362
+ * await client.subscribe(['events', 'status'])
363
+ * await client.subscribe(['response'])
213
364
  */
214
- async #subscribe () {
215
- const topics = getSubscriptionTopics(this.deviceId)
365
+ async subscribe (types = 'all') {
366
+ let topicsToSubscribe = []
367
+
368
+ if (types === 'all') {
369
+ // Subscribe to all device topics
370
+ topicsToSubscribe = getSubscriptionTopics(this.deviceId)
371
+ } else {
372
+ // Subscribe to specific topic types
373
+ const typeArray = Array.isArray(types) ? types : [types]
374
+
375
+ for (const type of typeArray) {
376
+ if (type === 'events') {
377
+ topicsToSubscribe.push(...getEventsTopic(this.deviceId))
378
+ } else if (type === 'status') {
379
+ topicsToSubscribe.push(...getStatusTopic(this.deviceId))
380
+ } else if (type === 'response') {
381
+ topicsToSubscribe.push(getResponseTopic(this.deviceId))
382
+ } else {
383
+ throw new Error(`Invalid topic type: ${type}. Must be 'events', 'status', 'response', or 'all'`)
384
+ }
385
+ }
386
+ }
216
387
 
217
388
  // Wait for all subscriptions to complete
218
389
  await Promise.all(
219
- topics.map((topic) => {
390
+ topicsToSubscribe.map((topic) => {
220
391
  return new Promise((resolve, reject) => {
221
392
  this.mqttClient.subscribe(topic, (err) => {
222
393
  if (err) {
@@ -232,6 +403,14 @@ export class YotoMqttClient extends EventEmitter {
232
403
  )
233
404
  }
234
405
 
406
+ /**
407
+ * Subscribe to device topics (internal - used by autoSubscribe)
408
+ * @returns {Promise<void>}
409
+ */
410
+ async #subscribe () {
411
+ return this.subscribe('all')
412
+ }
413
+
235
414
  /**
236
415
  * Handle incoming MQTT message
237
416
  * @param {string} topic - MQTT topic
@@ -245,21 +424,22 @@ export class YotoMqttClient extends EventEmitter {
245
424
  return
246
425
  }
247
426
 
248
- // Ignore unknown message types
249
- if (messageType === 'unknown') {
250
- return
251
- }
252
-
253
427
  try {
254
428
  const payload = JSON.parse(message.toString())
255
429
 
256
- // Emit typed events based on message type
430
+ // Emit typed events based on message type, including raw topic
257
431
  if (messageType === 'events') {
258
- this.emit('events', /** @type {YotoEventsMessage} */ (payload))
432
+ this.emit('events', topic, /** @type {YotoEventsMessage} */ (payload))
259
433
  } else if (messageType === 'status') {
260
- this.emit('status', /** @type {YotoStatusMessage} */ (payload))
434
+ this.emit('status', topic, /** @type {YotoStatusMessage} */ (payload))
435
+ } else if (messageType === 'status-legacy') {
436
+ // Legacy status topic contains lifecycle events (shutdown, startup) and full hardware diagnostics
437
+ // The documented /data/status topic does not include these critical lifecycle fields
438
+ this.emit('status-legacy', topic, /** @type {YotoStatusLegacyMessage} */ (payload))
261
439
  } else if (messageType === 'response') {
262
- this.emit('response', /** @type {YotoResponseMessage} */ (payload))
440
+ this.emit('response', topic, /** @type {YotoResponseMessage} */ (payload))
441
+ } else if (messageType === 'unknown') {
442
+ this.emit('unknown', topic, payload)
263
443
  }
264
444
  } catch (err) {
265
445
  const error = /** @type {Error} */ (err)
@@ -346,20 +526,22 @@ export class YotoMqttClient extends EventEmitter {
346
526
 
347
527
  /**
348
528
  * Request current events from device
529
+ * @param {string} [body=''] - Optional request body for tracking/identification
349
530
  * @returns {Promise<void>}
350
531
  */
351
- async requestEvents () {
532
+ async requestEvents (body = '') {
352
533
  const topic = getEventsRequestTopic(this.deviceId)
353
- return this.#publish(topic, '')
534
+ return this.#publish(topic, body)
354
535
  }
355
536
 
356
537
  /**
357
538
  * Request current status from device
539
+ * @param {string} [body=''] - Optional request body for tracking/identification
358
540
  * @returns {Promise<void>}
359
541
  */
360
- async requestStatus () {
542
+ async requestStatus (body = '') {
361
543
  const topic = getStatusRequestTopic(this.deviceId)
362
- return this.#publish(topic, '')
544
+ return this.#publish(topic, body)
363
545
  }
364
546
 
365
547
  /**
@@ -19,8 +19,25 @@
19
19
  *
20
20
  * await client.connect()
21
21
  * ```
22
+ *
23
+ * @example
24
+ * ```javascript
25
+ * // Disable automatic reconnection
26
+ * const client = createYotoMqttClient({
27
+ * deviceId: 'abc123',
28
+ * accessToken: 'token',
29
+ * mqttOptions: {
30
+ * reconnectPeriod: 0, // Disable auto-reconnect (default is 5000ms)
31
+ * connectTimeout: 30000 // 30 second connection timeout
32
+ * }
33
+ * })
34
+ * ```
22
35
  */
23
36
  export function createYotoMqttClient(options: YotoMqttOptions): YotoMqttClient;
37
+ /**
38
+ * MQTT.js client options that can be passed to createYotoMqttClient
39
+ */
40
+ export type MqttClientOptions = Partial<mqtt.IClientOptions>;
24
41
  export type YotoMqttOptions = {
25
42
  /**
26
43
  * - Device ID to connect to
@@ -38,10 +55,6 @@ export type YotoMqttOptions = {
38
55
  * - MQTT broker URL
39
56
  */
40
57
  brokerUrl?: string;
41
- /**
42
- * - Keepalive interval in seconds
43
- */
44
- keepalive?: number;
45
58
  /**
46
59
  * - MQTT broker port
47
60
  */
@@ -50,6 +63,11 @@ export type YotoMqttOptions = {
50
63
  * - Auto-subscribe to device topics on connect
51
64
  */
52
65
  autoSubscribe?: boolean;
66
+ /**
67
+ * - Additional MQTT.js client options (defaults: reconnectPeriod=5000, keepalive=300; cannot override: clientId, username, password, protocol, ALPNProtocols)
68
+ */
69
+ mqttOptions?: MqttClientOptions;
53
70
  };
54
71
  import { YotoMqttClient } from './client.js';
72
+ import mqtt from 'mqtt';
55
73
  //# sourceMappingURL=factory.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"factory.d.ts","sourceRoot":"","sources":["factory.js"],"names":[],"mappings":"AAqCA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,8CApBW,eAAe,GACb,cAAc,CAkE1B;;;;;cAzFa,MAAM;;;;iBACN,MAAM;;;;qBACN,MAAM;;;;gBACN,MAAM;;;;gBACN,MAAM;;;;WACN,MAAM;;;;oBACN,OAAO;;+BAIU,aAAa"}
1
+ {"version":3,"file":"factory.d.ts","sourceRoot":"","sources":["factory.js"],"names":[],"mappings":"AA0CA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,8CAjCW,eAAe,GACb,cAAc,CAmF1B;;;;gCA/GY,OAAO,CAAC,mBAAc,CAAC;;;;;cAKtB,MAAM;;;;iBACN,MAAM;;;;qBACN,MAAM;;;;gBACN,MAAM;;;;WACN,MAAM;;;;oBACN,OAAO;;;;kBACP,iBAAiB;;+BAIA,aAAa;iBAD3B,MAAM"}
@@ -13,15 +13,20 @@
13
13
  // MQTT Factory: Create properly configured MQTT clients for Yoto devices
14
14
  // ============================================================================
15
15
 
16
+ /**
17
+ * MQTT.js client options that can be passed to createYotoMqttClient
18
+ * @typedef {Partial<IClientOptions>} MqttClientOptions
19
+ */
20
+
16
21
  /**
17
22
  * @typedef {Object} YotoMqttOptions
18
23
  * @property {string} deviceId - Device ID to connect to
19
24
  * @property {string} accessToken - JWT access token for authentication
20
25
  * @property {string} [clientIdPrefix='DASH'] - Prefix for MQTT client ID (default: 'DASH')
21
26
  * @property {string} [brokerUrl='wss://aqrphjqbp3u2z-ats.iot.eu-west-2.amazonaws.com'] - MQTT broker URL
22
- * @property {number} [keepalive=300] - Keepalive interval in seconds
23
27
  * @property {number} [port=443] - MQTT broker port
24
28
  * @property {boolean} [autoSubscribe=true] - Auto-subscribe to device topics on connect
29
+ * @property {MqttClientOptions} [mqttOptions] - Additional MQTT.js client options (defaults: reconnectPeriod=5000, keepalive=300; cannot override: clientId, username, password, protocol, ALPNProtocols)
25
30
  */
26
31
 
27
32
  import mqtt from 'mqtt'
@@ -56,6 +61,19 @@ import {
56
61
  *
57
62
  * await client.connect()
58
63
  * ```
64
+ *
65
+ * @example
66
+ * ```javascript
67
+ * // Disable automatic reconnection
68
+ * const client = createYotoMqttClient({
69
+ * deviceId: 'abc123',
70
+ * accessToken: 'token',
71
+ * mqttOptions: {
72
+ * reconnectPeriod: 0, // Disable auto-reconnect (default is 5000ms)
73
+ * connectTimeout: 30000 // 30 second connection timeout
74
+ * }
75
+ * })
76
+ * ```
59
77
  */
60
78
  export function createYotoMqttClient (options) {
61
79
  // Validate required options
@@ -73,9 +91,9 @@ export function createYotoMqttClient (options) {
73
91
  accessToken,
74
92
  clientIdPrefix = 'DASH',
75
93
  brokerUrl = MQTT_BROKER_URL,
76
- keepalive = MQTT_KEEPALIVE,
77
94
  port = MQTT_PORT,
78
- autoSubscribe = true
95
+ autoSubscribe = true,
96
+ mqttOptions: additionalMqttOptions = {}
79
97
  } = options
80
98
 
81
99
  // Build MQTT client ID
@@ -85,15 +103,19 @@ export function createYotoMqttClient (options) {
85
103
  const username = `${deviceId}?x-amz-customauthorizer-name=${MQTT_AUTH_NAME}`
86
104
 
87
105
  // Create MQTT connection options
106
+ // Merge additional options first, then override with required Yoto settings
88
107
  /** @type {IClientOptions} */
89
108
  const mqttOptions = {
109
+ // Defaults (can be overridden by additionalMqttOptions)
110
+ reconnectPeriod: 5000, // Default: auto-reconnect after 5 seconds
111
+ keepalive: MQTT_KEEPALIVE, // Default: 300 seconds (5 minutes)
112
+ ...additionalMqttOptions, // Allow overriding defaults
113
+ // Required Yoto-specific settings (cannot be overridden)
90
114
  clientId,
91
115
  username,
92
116
  password: accessToken,
93
- keepalive,
94
117
  port,
95
118
  protocol: MQTT_PROTOCOL,
96
- reconnectPeriod: 0, // Disable auto-reconnect, handle manually
97
119
  ALPNProtocols: MQTT_ALPN_PROTOCOLS
98
120
  }
99
121
 
@@ -1,13 +1,13 @@
1
1
  import test from 'node:test'
2
2
  import assert from 'node:assert'
3
3
  import { getDevices } from '../api-endpoints/devices.js'
4
- import { loadTestTokens, logResponse } from '../api-endpoints/test-helpers.js'
4
+ import { loadTestTokens, logResponse } from '../api-endpoints/endpoint-test-helpers.js'
5
5
  import { createYotoMqttClient } from './index.js'
6
6
 
7
7
  const { accessToken } = loadTestTokens()
8
8
 
9
9
  test('MQTT client', async (t) => {
10
- await t.test('should connect, request status/events, and receive response and events messages', async () => {
10
+ await t.test('should connect, request status/events, and receive response and events messages (status-legacy is optional)', async () => {
11
11
  // Get an online device
12
12
  const response = await getDevices({ accessToken })
13
13
  assert.ok(response.devices.length > 0, 'Should have at least one device')
@@ -28,23 +28,26 @@ test('MQTT client', async (t) => {
28
28
  let eventsResponseReceived = false
29
29
  let eventsMessageReceived = false
30
30
  let statusMessageReceived = false
31
+ let statusLegacyMessageReceived = false
31
32
  /** @type {Error[]} */
32
33
  const errors = []
33
34
 
34
35
  // Setup message handlers
35
- mqttClient.on('events', (message) => {
36
- logResponse('MQTT events message', message)
36
+ mqttClient.on('events', (topic, payload) => {
37
+ logResponse('MQTT events message', { topic, payload })
37
38
 
38
39
  try {
39
40
  // Validate events message structure
40
- assert.ok(message, 'Events message should exist')
41
+ assert.ok(payload, 'Events message should exist')
42
+ assert.ok(typeof topic === 'string', 'Topic should be a string')
43
+ assert.ok(topic.includes('/data/events'), 'Topic should contain /data/events')
41
44
 
42
45
  // Events messages are partial - not all fields are always present
43
- if (message.playbackStatus !== undefined) {
44
- assert.ok(typeof message.playbackStatus === 'string', 'playbackStatus should be string')
46
+ if (payload.playbackStatus !== undefined) {
47
+ assert.ok(typeof payload.playbackStatus === 'string', 'playbackStatus should be string')
45
48
  }
46
- if (message.cardId !== undefined) {
47
- assert.ok(typeof message.cardId === 'string', 'cardId should be string')
49
+ if (payload.cardId !== undefined) {
50
+ assert.ok(typeof payload.cardId === 'string', 'cardId should be string')
48
51
  }
49
52
 
50
53
  eventsMessageReceived = true
@@ -53,18 +56,20 @@ test('MQTT client', async (t) => {
53
56
  }
54
57
  })
55
58
 
56
- mqttClient.on('status', (message) => {
57
- logResponse('MQTT status message', message)
59
+ mqttClient.on('status', (topic, payload) => {
60
+ logResponse('MQTT status message', { topic, payload })
58
61
 
59
62
  try {
60
63
  // Validate status message structure
61
- assert.ok(message, 'Status message should exist')
62
- assert.ok(message.status, 'Status message should have status object')
63
- assert.ok(typeof message.status.statusVersion === 'number', 'Should have statusVersion number')
64
- assert.ok(typeof message.status.fwVersion === 'string', 'Should have fwVersion string')
65
- assert.ok(typeof message.status.productType === 'string', 'Should have productType string')
66
- assert.ok(typeof message.status.batteryLevel === 'number', 'Should have batteryLevel number')
67
- assert.ok(typeof message.status.volume === 'number', 'Should have volume number')
64
+ assert.ok(payload, 'Status message should exist')
65
+ assert.ok(typeof topic === 'string', 'Topic should be a string')
66
+ assert.ok(topic.includes('/data/status'), 'Topic should contain /data/status')
67
+ assert.ok(payload.status, 'Status message should have status object')
68
+ assert.ok(typeof payload.status.statusVersion === 'number', 'Should have statusVersion number')
69
+ assert.ok(typeof payload.status.fwVersion === 'string', 'Should have fwVersion string')
70
+ assert.ok(typeof payload.status.productType === 'string', 'Should have productType string')
71
+ assert.ok(typeof payload.status.batteryLevel === 'number', 'Should have batteryLevel number')
72
+ assert.ok(typeof payload.status.volume === 'number', 'Should have volume number')
68
73
 
69
74
  statusMessageReceived = true
70
75
  } catch (err) {
@@ -72,28 +77,71 @@ test('MQTT client', async (t) => {
72
77
  }
73
78
  })
74
79
 
75
- mqttClient.on('response', (message) => {
76
- logResponse('MQTT response message', message)
80
+ mqttClient.on('status-legacy', (topic, payload) => {
81
+ logResponse('MQTT status-legacy message', { topic, payload })
82
+
83
+ try {
84
+ // Validate legacy status message structure
85
+ assert.ok(payload, 'Legacy status message should exist')
86
+ assert.ok(typeof topic === 'string', 'Topic should be a string')
87
+ assert.ok(!topic.includes('/data/'), 'Legacy topic should NOT contain /data/')
88
+ assert.ok(payload.status, 'Legacy status message should have status object')
89
+
90
+ // Validate lifecycle fields unique to legacy format
91
+ assert.ok(typeof payload.status.statusVersion === 'number', 'Should have statusVersion number')
92
+ assert.ok(typeof payload.status.fwVersion === 'string', 'Should have fwVersion string')
93
+
94
+ // shutDown field is the key field for lifecycle events
95
+ if (payload.status.shutDown !== undefined) {
96
+ assert.ok(typeof payload.status.shutDown === 'string', 'shutDown should be string')
97
+ }
98
+
99
+ // upTime and utcTime are key for startup detection
100
+ if (payload.status.upTime !== undefined) {
101
+ assert.ok(typeof payload.status.upTime === 'number', 'upTime should be number')
102
+ }
103
+ if (payload.status.utcTime !== undefined) {
104
+ assert.ok(typeof payload.status.utcTime === 'number', 'utcTime should be number')
105
+ }
106
+
107
+ // Hardware diagnostic fields unique to legacy
108
+ if (payload.status.battery !== undefined) {
109
+ assert.ok(typeof payload.status.battery === 'number', 'battery should be number')
110
+ }
111
+ if (payload.status.wifiStrength !== undefined) {
112
+ assert.ok(typeof payload.status.wifiStrength === 'number', 'wifiStrength should be number')
113
+ }
114
+
115
+ statusLegacyMessageReceived = true
116
+ } catch (err) {
117
+ errors.push(/** @type {Error} */ (err))
118
+ }
119
+ })
120
+
121
+ mqttClient.on('response', (topic, payload) => {
122
+ logResponse('MQTT response message', { topic, payload })
77
123
 
78
124
  try {
79
125
  // Validate response message structure
80
- assert.ok(message, 'Response message should exist')
81
- assert.ok(message.status, 'Response should have status object')
82
- assert.ok(typeof message.status.req_body === 'string', 'Response should have req_body string')
126
+ assert.ok(payload, 'Response message should exist')
127
+ assert.ok(typeof topic === 'string', 'Topic should be a string')
128
+ assert.ok(topic.includes('/response'), 'Topic should contain /response')
129
+ assert.ok(payload.status, 'Response should have status object')
130
+ assert.ok(typeof payload.status.req_body === 'string', 'Response should have req_body string')
83
131
 
84
132
  // Check if status request was acknowledged (field name is 'status/request')
85
- if (message.status['status/request']) {
133
+ if (payload.status['status/request']) {
86
134
  assert.ok(
87
- message.status['status/request'] === 'OK' || message.status['status/request'] === 'FAIL',
135
+ payload.status['status/request'] === 'OK' || payload.status['status/request'] === 'FAIL',
88
136
  'Status request field should be OK or FAIL'
89
137
  )
90
138
  statusResponseReceived = true
91
139
  }
92
140
 
93
141
  // Check if events request was acknowledged
94
- if (message.status.events) {
142
+ if (payload.status.events) {
95
143
  assert.ok(
96
- message.status.events === 'OK' || message.status.events === 'FAIL',
144
+ payload.status.events === 'OK' || payload.status.events === 'FAIL',
97
145
  'Events request field should be OK or FAIL'
98
146
  )
99
147
  eventsResponseReceived = true
@@ -125,7 +173,7 @@ test('MQTT client', async (t) => {
125
173
  // Wait for messages (with timeout)
126
174
  await new Promise((resolve, reject) => {
127
175
  timeoutId = setTimeout(() => {
128
- reject(new Error(`Timeout waiting for messages. statusResponse=${statusResponseReceived}, eventsResponse=${eventsResponseReceived}, eventsMessage=${eventsMessageReceived}, statusMessage=${statusMessageReceived}`))
176
+ reject(new Error(`Timeout waiting for messages. statusResponse=${statusResponseReceived}, eventsResponse=${eventsResponseReceived}, eventsMessage=${eventsMessageReceived}, statusMessage=${statusMessageReceived}, statusLegacy=${statusLegacyMessageReceived} (optional)`))
129
177
  }, 5000) // 5 second timeout
130
178
 
131
179
  checkIntervalId = setInterval(() => {
@@ -134,6 +182,8 @@ test('MQTT client', async (t) => {
134
182
  clearInterval(checkIntervalId)
135
183
  reject(errors[0])
136
184
  }
185
+ // Note: status-legacy is NOT required - it doesn't respond to requestStatus()
186
+ // It only emits on real-time lifecycle events (shutdown/startup) or 5-minute periodic updates
137
187
  if (statusResponseReceived && eventsResponseReceived && eventsMessageReceived && statusMessageReceived) {
138
188
  clearTimeout(timeoutId)
139
189
  clearInterval(checkIntervalId)
@@ -147,6 +197,13 @@ test('MQTT client', async (t) => {
147
197
  assert.ok(eventsResponseReceived, 'Should have received events request response')
148
198
  assert.ok(eventsMessageReceived, 'Should have received events data message')
149
199
  assert.ok(statusMessageReceived, 'Should have received status data message')
200
+
201
+ // Note: status-legacy message is optional in this test because it does NOT respond to requestStatus()
202
+ // It only emits on real-time lifecycle events (shutdown/startup) or 5-minute periodic updates
203
+ // If we happened to catch one during the test window, validate it was received correctly
204
+ if (statusLegacyMessageReceived) {
205
+ console.log('✓ Received optional status-legacy message with lifecycle events')
206
+ }
150
207
  } catch (err) {
151
208
  // Clean up timers on error
152
209
  if (timeoutId) clearTimeout(timeoutId)