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.
- package/README.md +523 -30
- package/bin/auth.js +36 -46
- package/bin/content.js +0 -0
- package/bin/device-model.d.ts +3 -0
- package/bin/device-model.d.ts.map +1 -0
- package/bin/device-model.js +360 -0
- package/bin/device-tui.TODO.md +125 -0
- package/bin/device-tui.d.ts +31 -0
- package/bin/device-tui.d.ts.map +1 -0
- package/bin/device-tui.js +1123 -0
- package/bin/devices.js +166 -28
- package/bin/groups.js +0 -0
- package/bin/icons.js +0 -0
- package/bin/lib/cli-helpers.d.ts +1 -1
- package/bin/lib/cli-helpers.d.ts.map +1 -1
- package/bin/lib/cli-helpers.js +5 -5
- package/bin/refresh-token.js +6 -6
- package/bin/token-info.js +3 -3
- package/index.d.ts +4 -585
- package/index.d.ts.map +1 -1
- package/index.js +11 -689
- package/lib/api-client.d.ts +576 -0
- package/lib/api-client.d.ts.map +1 -0
- package/lib/api-client.js +681 -0
- package/lib/api-endpoints/auth.d.ts +199 -8
- package/lib/api-endpoints/auth.d.ts.map +1 -1
- package/lib/api-endpoints/auth.js +224 -7
- package/lib/api-endpoints/auth.test.js +54 -2
- package/lib/api-endpoints/constants.d.ts +14 -8
- package/lib/api-endpoints/constants.d.ts.map +1 -1
- package/lib/api-endpoints/constants.js +17 -10
- package/lib/api-endpoints/content.test.js +1 -1
- package/lib/api-endpoints/devices.d.ts +405 -117
- package/lib/api-endpoints/devices.d.ts.map +1 -1
- package/lib/api-endpoints/devices.js +114 -52
- package/lib/api-endpoints/devices.test.js +1 -1
- package/lib/api-endpoints/{test-helpers.d.ts → endpoint-test-helpers.d.ts} +1 -1
- package/lib/api-endpoints/endpoint-test-helpers.d.ts.map +1 -0
- package/lib/api-endpoints/family-library-groups.test.js +1 -1
- package/lib/api-endpoints/family.test.js +1 -1
- package/lib/api-endpoints/icons.test.js +1 -1
- package/lib/helpers/power-state.d.ts +53 -0
- package/lib/helpers/power-state.d.ts.map +1 -0
- package/lib/helpers/power-state.js +73 -0
- package/lib/helpers/power-state.test.js +100 -0
- package/lib/helpers/temperature.d.ts +24 -0
- package/lib/helpers/temperature.d.ts.map +1 -0
- package/lib/helpers/temperature.js +61 -0
- package/lib/helpers/temperature.test.js +58 -0
- package/lib/helpers/typed-keys.d.ts +7 -0
- package/lib/helpers/typed-keys.d.ts.map +1 -0
- package/lib/helpers/typed-keys.js +8 -0
- package/lib/mqtt/client.d.ts +348 -22
- package/lib/mqtt/client.d.ts.map +1 -1
- package/lib/mqtt/client.js +213 -31
- package/lib/mqtt/factory.d.ts +22 -4
- package/lib/mqtt/factory.d.ts.map +1 -1
- package/lib/mqtt/factory.js +27 -5
- package/lib/mqtt/mqtt.test.js +85 -28
- package/lib/mqtt/topics.d.ts +41 -13
- package/lib/mqtt/topics.d.ts.map +1 -1
- package/lib/mqtt/topics.js +54 -20
- package/lib/pkg.d.cts +8 -0
- package/lib/token.d.ts +21 -6
- package/lib/token.d.ts.map +1 -1
- package/lib/token.js +30 -23
- package/lib/yoto-account.d.ts +163 -0
- package/lib/yoto-account.d.ts.map +1 -0
- package/lib/yoto-account.js +340 -0
- package/lib/yoto-device.d.ts +656 -0
- package/lib/yoto-device.d.ts.map +1 -0
- package/lib/yoto-device.js +2850 -0
- package/package.json +21 -15
- package/lib/api-endpoints/test-helpers.d.ts.map +0 -1
- /package/lib/api-endpoints/{test-helpers.js → endpoint-test-helpers.js} +0 -0
package/lib/mqtt/client.js
CHANGED
|
@@ -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
|
|
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
|
|
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 {
|
|
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 -
|
|
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 -
|
|
66
|
-
* @property {string} temp - Temperature reading
|
|
67
|
-
* @property {
|
|
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
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
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
|
|
215
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/lib/mqtt/factory.d.ts
CHANGED
|
@@ -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":"
|
|
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"}
|
package/lib/mqtt/factory.js
CHANGED
|
@@ -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
|
|
package/lib/mqtt/mqtt.test.js
CHANGED
|
@@ -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', (
|
|
36
|
-
logResponse('MQTT events 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(
|
|
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 (
|
|
44
|
-
assert.ok(typeof
|
|
46
|
+
if (payload.playbackStatus !== undefined) {
|
|
47
|
+
assert.ok(typeof payload.playbackStatus === 'string', 'playbackStatus should be string')
|
|
45
48
|
}
|
|
46
|
-
if (
|
|
47
|
-
assert.ok(typeof
|
|
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', (
|
|
57
|
-
logResponse('MQTT status 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(
|
|
62
|
-
assert.ok(
|
|
63
|
-
assert.ok(
|
|
64
|
-
assert.ok(
|
|
65
|
-
assert.ok(typeof
|
|
66
|
-
assert.ok(typeof
|
|
67
|
-
assert.ok(typeof
|
|
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('
|
|
76
|
-
logResponse('MQTT
|
|
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(
|
|
81
|
-
assert.ok(
|
|
82
|
-
assert.ok(
|
|
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 (
|
|
133
|
+
if (payload.status['status/request']) {
|
|
86
134
|
assert.ok(
|
|
87
|
-
|
|
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 (
|
|
142
|
+
if (payload.status.events) {
|
|
95
143
|
assert.ok(
|
|
96
|
-
|
|
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)
|