yoto-nodejs-client 0.0.1

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 (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +736 -0
  3. package/bin/auth.d.ts +3 -0
  4. package/bin/auth.d.ts.map +1 -0
  5. package/bin/auth.js +130 -0
  6. package/bin/content.d.ts +3 -0
  7. package/bin/content.d.ts.map +1 -0
  8. package/bin/content.js +117 -0
  9. package/bin/devices.d.ts +3 -0
  10. package/bin/devices.d.ts.map +1 -0
  11. package/bin/devices.js +239 -0
  12. package/bin/groups.d.ts +3 -0
  13. package/bin/groups.d.ts.map +1 -0
  14. package/bin/groups.js +80 -0
  15. package/bin/icons.d.ts +3 -0
  16. package/bin/icons.d.ts.map +1 -0
  17. package/bin/icons.js +100 -0
  18. package/bin/lib/cli-helpers.d.ts +21 -0
  19. package/bin/lib/cli-helpers.d.ts.map +1 -0
  20. package/bin/lib/cli-helpers.js +140 -0
  21. package/bin/lib/token-helpers.d.ts +14 -0
  22. package/bin/lib/token-helpers.d.ts.map +1 -0
  23. package/bin/lib/token-helpers.js +151 -0
  24. package/bin/refresh-token.d.ts +3 -0
  25. package/bin/refresh-token.d.ts.map +1 -0
  26. package/bin/refresh-token.js +168 -0
  27. package/bin/token-info.d.ts +3 -0
  28. package/bin/token-info.d.ts.map +1 -0
  29. package/bin/token-info.js +351 -0
  30. package/index.d.ts +218 -0
  31. package/index.d.ts.map +1 -0
  32. package/index.js +689 -0
  33. package/lib/api-endpoints/auth.d.ts +56 -0
  34. package/lib/api-endpoints/auth.d.ts.map +1 -0
  35. package/lib/api-endpoints/auth.js +209 -0
  36. package/lib/api-endpoints/auth.test.js +27 -0
  37. package/lib/api-endpoints/constants.d.ts +6 -0
  38. package/lib/api-endpoints/constants.d.ts.map +1 -0
  39. package/lib/api-endpoints/constants.js +31 -0
  40. package/lib/api-endpoints/content.d.ts +275 -0
  41. package/lib/api-endpoints/content.d.ts.map +1 -0
  42. package/lib/api-endpoints/content.js +518 -0
  43. package/lib/api-endpoints/content.test.js +250 -0
  44. package/lib/api-endpoints/devices.d.ts +202 -0
  45. package/lib/api-endpoints/devices.d.ts.map +1 -0
  46. package/lib/api-endpoints/devices.js +404 -0
  47. package/lib/api-endpoints/devices.test.js +483 -0
  48. package/lib/api-endpoints/family-library-groups.d.ts +75 -0
  49. package/lib/api-endpoints/family-library-groups.d.ts.map +1 -0
  50. package/lib/api-endpoints/family-library-groups.js +247 -0
  51. package/lib/api-endpoints/family-library-groups.test.js +272 -0
  52. package/lib/api-endpoints/family.d.ts +39 -0
  53. package/lib/api-endpoints/family.d.ts.map +1 -0
  54. package/lib/api-endpoints/family.js +166 -0
  55. package/lib/api-endpoints/family.test.js +184 -0
  56. package/lib/api-endpoints/helpers.d.ts +29 -0
  57. package/lib/api-endpoints/helpers.d.ts.map +1 -0
  58. package/lib/api-endpoints/helpers.js +104 -0
  59. package/lib/api-endpoints/icons.d.ts +62 -0
  60. package/lib/api-endpoints/icons.d.ts.map +1 -0
  61. package/lib/api-endpoints/icons.js +201 -0
  62. package/lib/api-endpoints/icons.test.js +118 -0
  63. package/lib/api-endpoints/media.d.ts +37 -0
  64. package/lib/api-endpoints/media.d.ts.map +1 -0
  65. package/lib/api-endpoints/media.js +155 -0
  66. package/lib/api-endpoints/test-helpers.d.ts +7 -0
  67. package/lib/api-endpoints/test-helpers.d.ts.map +1 -0
  68. package/lib/api-endpoints/test-helpers.js +64 -0
  69. package/lib/mqtt/client.d.ts +124 -0
  70. package/lib/mqtt/client.d.ts.map +1 -0
  71. package/lib/mqtt/client.js +558 -0
  72. package/lib/mqtt/commands.d.ts +69 -0
  73. package/lib/mqtt/commands.d.ts.map +1 -0
  74. package/lib/mqtt/commands.js +238 -0
  75. package/lib/mqtt/factory.d.ts +12 -0
  76. package/lib/mqtt/factory.d.ts.map +1 -0
  77. package/lib/mqtt/factory.js +107 -0
  78. package/lib/mqtt/index.d.ts +5 -0
  79. package/lib/mqtt/index.d.ts.map +1 -0
  80. package/lib/mqtt/index.js +81 -0
  81. package/lib/mqtt/mqtt.test.js +168 -0
  82. package/lib/mqtt/topics.d.ts +34 -0
  83. package/lib/mqtt/topics.d.ts.map +1 -0
  84. package/lib/mqtt/topics.js +295 -0
  85. package/lib/pkg.cjs +3 -0
  86. package/lib/pkg.d.cts +70 -0
  87. package/lib/pkg.d.cts.map +1 -0
  88. package/lib/token.d.ts +29 -0
  89. package/lib/token.d.ts.map +1 -0
  90. package/lib/token.js +240 -0
  91. package/package.json +91 -0
  92. package/yoto.png +0 -0
@@ -0,0 +1,124 @@
1
+ export class YotoMqttClient extends EventEmitter<any> {
2
+ constructor(mqttClient: MqttClient, deviceId: string, options?: {
3
+ autoSubscribe?: boolean | undefined;
4
+ });
5
+ mqttClient: MqttClient;
6
+ deviceId: string;
7
+ autoSubscribe: boolean;
8
+ commands: {
9
+ volume: typeof import("./commands.js").createVolumeCommand;
10
+ ambient: typeof import("./commands.js").createAmbientCommand;
11
+ ambientFromHex: typeof import("./commands.js").createAmbientCommandFromHex;
12
+ sleepTimer: typeof import("./commands.js").createSleepTimerCommand;
13
+ cardStart: typeof import("./commands.js").createCardStartCommand;
14
+ bluetoothOn: typeof import("./commands.js").createBluetoothOnCommand;
15
+ bluetoothSpeaker: typeof import("./commands.js").createBluetoothSpeakerCommand;
16
+ bluetoothAudioSource: typeof import("./commands.js").createBluetoothAudioSourceCommand;
17
+ displayPreview: typeof import("./commands.js").createDisplayPreviewCommand;
18
+ };
19
+ get state(): YotoMqttConnectionState;
20
+ get connected(): boolean;
21
+ connect(): Promise<void>;
22
+ disconnect(): Promise<void>;
23
+ requestEvents(): Promise<void>;
24
+ requestStatus(): Promise<void>;
25
+ setVolume(volume: number): Promise<void>;
26
+ setAmbient(r: number, g: number, b: number): Promise<void>;
27
+ setAmbientHex(hexColor: string): Promise<void>;
28
+ setSleepTimer(seconds: number): Promise<void>;
29
+ reboot(): Promise<void>;
30
+ startCard(options: {
31
+ uri: string;
32
+ chapterKey?: string | undefined;
33
+ trackKey?: string | undefined;
34
+ secondsIn?: number | undefined;
35
+ cutOff?: number | undefined;
36
+ anyButtonStop?: boolean | undefined;
37
+ }): Promise<void>;
38
+ stopCard(): Promise<void>;
39
+ pauseCard(): Promise<void>;
40
+ resumeCard(): Promise<void>;
41
+ bluetoothOn(options?: {
42
+ action?: string | undefined;
43
+ mode?: string | boolean | undefined;
44
+ rssi?: number | undefined;
45
+ name?: string | undefined;
46
+ mac?: string | undefined;
47
+ }): Promise<void>;
48
+ bluetoothOff(): Promise<void>;
49
+ bluetoothSpeakerMode(): Promise<void>;
50
+ bluetoothAudioSourceMode(): Promise<void>;
51
+ bluetoothDeleteBonds(): Promise<void>;
52
+ bluetoothConnect(): Promise<void>;
53
+ bluetoothDisconnect(): Promise<void>;
54
+ bluetoothGetState(): Promise<void>;
55
+ displayPreview(options: {
56
+ uri: string;
57
+ timeout: number;
58
+ animated: boolean;
59
+ }): Promise<void>;
60
+ #private;
61
+ }
62
+ export type YotoEventsMessage = {
63
+ repeatAll?: boolean;
64
+ streaming?: boolean;
65
+ volume?: number;
66
+ volumeMax?: number;
67
+ playbackWait?: boolean;
68
+ sleepTimerActive?: boolean;
69
+ eventUtc?: number;
70
+ trackLength?: number;
71
+ position?: number;
72
+ cardId?: string;
73
+ source?: string;
74
+ cardUpdatedAt?: string;
75
+ chapterTitle?: string;
76
+ chapterKey?: string;
77
+ trackTitle?: string;
78
+ trackKey?: string;
79
+ playbackStatus?: string;
80
+ sleepTimerSeconds?: number;
81
+ };
82
+ export type YotoStatusMessage = {
83
+ statusVersion: number;
84
+ fwVersion: string;
85
+ productType: string;
86
+ batteryLevel: number;
87
+ als: number;
88
+ freeDisk: number;
89
+ shutdownTimeout: number;
90
+ dbatTimeout: number;
91
+ charging: number;
92
+ activeCard: string;
93
+ cardInserted: number;
94
+ playingStatus: number;
95
+ headphones: boolean;
96
+ dnowBrightness: number;
97
+ dayBright: number;
98
+ nightBright: number;
99
+ bluetoothHp: boolean;
100
+ volume: number;
101
+ userVolume: number;
102
+ timeFormat: "12" | "24";
103
+ nightlightMode: string;
104
+ temp: string;
105
+ day: number;
106
+ };
107
+ export type YotoResponseMessage = {
108
+ status: {
109
+ volume?: "OK" | "FAIL" | undefined;
110
+ ambients?: "OK" | "FAIL" | undefined;
111
+ card?: "OK" | "FAIL" | undefined;
112
+ events?: "OK" | "FAIL" | undefined;
113
+ status?: "OK" | "FAIL" | undefined;
114
+ bluetooth?: "OK" | "FAIL" | undefined;
115
+ display?: "OK" | "FAIL" | undefined;
116
+ reboot?: "OK" | "FAIL" | undefined;
117
+ req_body: string;
118
+ sleepTimer: "OK" | "FAIL";
119
+ };
120
+ };
121
+ export type YotoMqttConnectionState = "disconnected" | "connected" | "reconnecting";
122
+ import { EventEmitter } from 'events';
123
+ import type { MqttClient } from 'mqtt';
124
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["client.js"],"names":[],"mappings":"AAgIA;IAQE,wBALW,UAAU,YACV,MAAM,YAEd;QAA0B,aAAa;KACzC,EAaA;IATC,uBAA4B;IAC5B,iBAAwB;IACxB,uBAAoD;IAMpD;;;;;;;;;;MAAwB;IAO1B,aAFa,uBAAuB,CAMnC;IAMD,iBAFa,OAAO,CAInB;IA2GD,WAFa,OAAO,CAAC,IAAI,CAAC,CA8BzB;IAMD,cAFa,OAAO,CAAC,IAAI,CAAC,CAiBzB;IA4BD,iBAFa,OAAO,CAAC,IAAI,CAAC,CAKzB;IAMD,iBAFa,OAAO,CAAC,IAAI,CAAC,CAKzB;IAOD,kBAHW,MAAM,GACJ,OAAO,CAAC,IAAI,CAAC,CAMzB;IASD,cALW,MAAM,KACN,MAAM,KACN,MAAM,GACJ,OAAO,CAAC,IAAI,CAAC,CAMzB;IAOD,wBAHW,MAAM,GACJ,OAAO,CAAC,IAAI,CAAC,CAMzB;IAOD,uBAHW,MAAM,GACJ,OAAO,CAAC,IAAI,CAAC,CAMzB;IAMD,UAFa,OAAO,CAAC,IAAI,CAAC,CAKzB;IAaD,mBARG;QAAwB,GAAG,EAAnB,MAAM;QACW,UAAU;QACV,QAAQ;QACR,SAAS;QACT,MAAM;QACL,aAAa;KACvC,GAAU,OAAO,CAAC,IAAI,CAAC,CAMzB;IAMD,YAFa,OAAO,CAAC,IAAI,CAAC,CAKzB;IAMD,aAFa,OAAO,CAAC,IAAI,CAAC,CAKzB;IAMD,cAFa,OAAO,CAAC,IAAI,CAAC,CAKzB;IAYD,sBAPG;QAAyB,MAAM;QACI,IAAI;QACd,IAAI;QACJ,IAAI;QACJ,GAAG;KAC5B,GAAU,OAAO,CAAC,IAAI,CAAC,CAMzB;IAMD,gBAFa,OAAO,CAAC,IAAI,CAAC,CAKzB;IAMD,wBAFa,OAAO,CAAC,IAAI,CAAC,CAMzB;IAMD,4BAFa,OAAO,CAAC,IAAI,CAAC,CAMzB;IAMD,wBAFa,OAAO,CAAC,IAAI,CAAC,CAKzB;IAMD,oBAFa,OAAO,CAAC,IAAI,CAAC,CAKzB;IAMD,uBAFa,OAAO,CAAC,IAAI,CAAC,CAKzB;IAMD,qBAFa,OAAO,CAAC,IAAI,CAAC,CAKzB;IAUD,wBALG;QAAwB,GAAG,EAAnB,MAAM;QACU,OAAO,EAAvB,MAAM;QACW,QAAQ,EAAzB,OAAO;KACf,GAAU,OAAO,CAAC,IAAI,CAAC,CAMzB;;CACF;;gBAzhBa,OAAO;gBACP,OAAO;aACP,MAAM;gBACN,MAAM;mBACN,OAAO;uBACP,OAAO;eACP,MAAM;kBACN,MAAM;eACN,MAAM;aACN,MAAM;aACN,MAAM;oBACN,MAAM;mBACN,MAAM;iBACN,MAAM;iBACN,MAAM;eACN,MAAM;qBACN,MAAM;wBACN,MAAM;;;mBAON,MAAM;eACN,MAAM;iBACN,MAAM;kBACN,MAAM;SACN,MAAM;cACN,MAAM;qBACN,MAAM;iBACN,MAAM;cACN,MAAM;gBACN,MAAM;kBACN,MAAM;mBACN,MAAM;gBACN,OAAO;oBACP,MAAM;eACN,MAAM;iBACN,MAAM;iBACN,OAAO;YACP,MAAM;gBACN,MAAM;gBACN,IAAI,GAAG,IAAI;oBACX,MAAM;UACN,MAAM;SACN,MAAM;;;YAQjB;QAAkC,MAAM;QACN,QAAQ;QACR,IAAI;QACJ,MAAM;QACN,MAAM;QACN,SAAS;QACT,OAAO;QACP,MAAM;QACd,QAAQ,EAAvB,MAAM;QACgB,UAAU,EAAhC,IAAI,GAAG,MAAM;KAE1B;;sCAIY,cAAc,GAAG,WAAW,GAAG,cAAc;6BAG7B,QAAQ;gCAhFN,MAAM"}
@@ -0,0 +1,558 @@
1
+ /**
2
+ * Yoto MQTT Client
3
+ *
4
+ * Wrapper around mqtt.js with Yoto-specific functionality
5
+ * @see https://yoto.dev/players-mqtt/
6
+ */
7
+
8
+ // ============================================================================
9
+ // MQTT Client: Yoto-specific wrapper around mqtt.js
10
+ // ============================================================================
11
+
12
+ /**
13
+ * @import { MqttClient } from 'mqtt'
14
+ */
15
+
16
+ /**
17
+ * Events message from device
18
+ * Note: Messages are partial - only changed fields are included
19
+ * @see https://yoto.dev/players-mqtt/mqtt-docs/#deviceiddataevents
20
+ * @typedef {Object} YotoEventsMessage
21
+ * @property {boolean} [repeatAll] - Repeat all tracks
22
+ * @property {boolean} [streaming] - Whether streaming
23
+ * @property {number} [volume] - Current volume level
24
+ * @property {number} [volumeMax] - Maximum volume level
25
+ * @property {boolean} [playbackWait] - Playback waiting
26
+ * @property {boolean} [sleepTimerActive] - Sleep timer active
27
+ * @property {number} [eventUtc] - Unix timestamp
28
+ * @property {number} [trackLength] - Track duration in seconds
29
+ * @property {number} [position] - Current position in seconds
30
+ * @property {string} [cardId] - Currently playing card ID
31
+ * @property {string} [source] - Source of playback (e.g., "card", "remote", "MQTT")
32
+ * @property {string} [cardUpdatedAt] - ISO8601 format timestamp
33
+ * @property {string} [chapterTitle] - Current chapter title
34
+ * @property {string} [chapterKey] - Current chapter key
35
+ * @property {string} [trackTitle] - Current track title
36
+ * @property {string} [trackKey] - Current track key
37
+ * @property {string} [playbackStatus] - Playback status (e.g., "playing", "paused", "stopped")
38
+ * @property {number} [sleepTimerSeconds] - Seconds remaining on sleep timer
39
+ */
40
+
41
+ /**
42
+ * Status message from device
43
+ * @see https://yoto.dev/players-mqtt/mqtt-docs/#deviceiddatastatus
44
+ * @typedef {Object} YotoStatusMessage
45
+ * @property {number} statusVersion - Status message version
46
+ * @property {string} fwVersion - Firmware version
47
+ * @property {string} productType - Product type identifier
48
+ * @property {number} batteryLevel - Battery level percentage
49
+ * @property {number} als - Ambient light sensor reading
50
+ * @property {number} freeDisk - Free disk space
51
+ * @property {number} shutdownTimeout - Shutdown timeout in seconds
52
+ * @property {number} dbatTimeout - DBAT timeout
53
+ * @property {number} charging - Charging state (0 or 1)
54
+ * @property {string} activeCard - Active card ID
55
+ * @property {number} cardInserted - Card insertion state
56
+ * @property {number} playingStatus - Playing status code
57
+ * @property {boolean} headphones - Headphones connected
58
+ * @property {number} dnowBrightness - Current display brightness
59
+ * @property {number} dayBright - Day brightness setting
60
+ * @property {number} nightBright - Night brightness setting
61
+ * @property {boolean} bluetoothHp - Bluetooth headphones enabled
62
+ * @property {number} volume - Current volume
63
+ * @property {number} userVolume - User volume setting
64
+ * @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)
68
+ */
69
+
70
+ /**
71
+ * Response message from device
72
+ * @see https://yoto.dev/players-mqtt/mqtt-docs/#deviceidresponse
73
+ * @typedef {Object} YotoResponseMessage
74
+ * @property {Object} status - Status object with dynamic resource keys
75
+ * @property {'OK' | 'FAIL'} [status.volume] - Volume command result
76
+ * @property {'OK' | 'FAIL'} [status.ambients] - Ambients command result
77
+ * @property {'OK' | 'FAIL'} [status.card] - Card command result
78
+ * @property {'OK' | 'FAIL'} [status.events] - Events request result
79
+ * @property {'OK' | 'FAIL'} [status.status] - Status request result
80
+ * @property {'OK' | 'FAIL'} [status.bluetooth] - Bluetooth command result
81
+ * @property {'OK' | 'FAIL'} [status.display] - Display command result
82
+ * @property {'OK' | 'FAIL'} [status.reboot] - Reboot command result
83
+ * @property {string} status.req_body - Stringified JSON from original request
84
+ * @property {'OK' | 'FAIL'} status.sleepTimer - Sleep timer command result (note: actual field name is 'sleep-timer')
85
+
86
+ */
87
+
88
+ /**
89
+ * MQTT connection state
90
+ * @typedef {'disconnected' | 'connected' | 'reconnecting'} YotoMqttConnectionState
91
+ */
92
+
93
+ import { EventEmitter } from 'events'
94
+ import {
95
+ getSubscriptionTopics,
96
+ parseTopic,
97
+ getEventsRequestTopic,
98
+ getStatusRequestTopic,
99
+ getVolumeSetTopic,
100
+ getAmbientsSetTopic,
101
+ getSleepTimerSetTopic,
102
+ getRebootTopic,
103
+ getCardStartTopic,
104
+ getCardStopTopic,
105
+ getCardPauseTopic,
106
+ getCardResumeTopic,
107
+ getBluetoothOnTopic,
108
+ getBluetoothOffTopic,
109
+ getBluetoothDeleteBondsTopic,
110
+ getBluetoothConnectTopic,
111
+ getBluetoothDisconnectTopic,
112
+ getBluetoothStateTopic,
113
+ getDisplayPreviewTopic
114
+ } from './topics.js'
115
+ import { commands } from './commands.js'
116
+
117
+ /**
118
+ * Yoto MQTT Client class
119
+ * @extends EventEmitter
120
+ *
121
+ * @fires YotoMqttClient#events
122
+ * @fires YotoMqttClient#status
123
+ * @fires YotoMqttClient#response
124
+ * @fires YotoMqttClient#connected
125
+ * @fires YotoMqttClient#disconnected
126
+ * @fires YotoMqttClient#reconnecting
127
+ * @fires YotoMqttClient#error
128
+ */
129
+ export class YotoMqttClient extends EventEmitter {
130
+ /**
131
+ * Create a Yoto MQTT client
132
+ * @param {MqttClient} mqttClient - Underlying MQTT client
133
+ * @param {string} deviceId - Device ID
134
+ * @param {Object} [options] - Client options
135
+ * @param {boolean} [options.autoSubscribe=true] - Auto-subscribe to device topics on connect
136
+ */
137
+ constructor (mqttClient, deviceId, options = {}) {
138
+ super()
139
+
140
+ this.mqttClient = mqttClient
141
+ this.deviceId = deviceId
142
+ this.autoSubscribe = options.autoSubscribe !== false
143
+
144
+ // Bind MQTT event handlers
145
+ this.#setupEventHandlers()
146
+
147
+ // Export command builders for convenience
148
+ this.commands = commands
149
+ }
150
+
151
+ /**
152
+ * Get current connection state
153
+ * @returns {YotoMqttConnectionState}
154
+ */
155
+ get state () {
156
+ if (this.mqttClient.connected) return 'connected'
157
+ if (this.mqttClient.reconnecting) return 'reconnecting'
158
+ return 'disconnected'
159
+ }
160
+
161
+ /**
162
+ * Check if client is connected
163
+ * @returns {boolean}
164
+ */
165
+ get connected () {
166
+ return this.mqttClient.connected
167
+ }
168
+
169
+ /**
170
+ * Setup MQTT event handlers
171
+ */
172
+ #setupEventHandlers () {
173
+ // Connection established
174
+ this.mqttClient.on('connect', async () => {
175
+ // Auto-subscribe to device topics if enabled
176
+ if (this.autoSubscribe) {
177
+ await this.#subscribe()
178
+ }
179
+
180
+ // Only emit connected after subscriptions are ready
181
+ this.emit('connected')
182
+ })
183
+
184
+ // Connection lost
185
+ this.mqttClient.on('disconnect', () => {
186
+ this.emit('disconnected')
187
+ })
188
+
189
+ // Connection closed
190
+ this.mqttClient.on('close', () => {
191
+ this.emit('disconnected')
192
+ })
193
+
194
+ // Reconnecting
195
+ this.mqttClient.on('reconnect', () => {
196
+ this.emit('reconnecting')
197
+ })
198
+
199
+ // Error occurred
200
+ this.mqttClient.on('error', (error) => {
201
+ this.emit('error', error)
202
+ })
203
+
204
+ // Message received
205
+ this.mqttClient.on('message', (topic, message) => {
206
+ this.#handleMessage(topic, message)
207
+ })
208
+ }
209
+
210
+ /**
211
+ * Subscribe to device topics
212
+ * @returns {Promise<void>}
213
+ */
214
+ async #subscribe () {
215
+ const topics = getSubscriptionTopics(this.deviceId)
216
+
217
+ // Wait for all subscriptions to complete
218
+ await Promise.all(
219
+ topics.map((topic) => {
220
+ return new Promise((resolve, reject) => {
221
+ this.mqttClient.subscribe(topic, (err) => {
222
+ if (err) {
223
+ const error = new Error(`Failed to subscribe to ${topic}: ${err.message}`)
224
+ this.emit('error', error)
225
+ reject(error)
226
+ } else {
227
+ resolve(undefined)
228
+ }
229
+ })
230
+ })
231
+ })
232
+ )
233
+ }
234
+
235
+ /**
236
+ * Handle incoming MQTT message
237
+ * @param {string} topic - MQTT topic
238
+ * @param {Buffer} message - Message payload
239
+ */
240
+ #handleMessage (topic, message) {
241
+ const { deviceId, messageType } = parseTopic(topic)
242
+
243
+ // Ignore messages from other devices
244
+ if (deviceId !== this.deviceId) {
245
+ return
246
+ }
247
+
248
+ // Ignore unknown message types
249
+ if (messageType === 'unknown') {
250
+ return
251
+ }
252
+
253
+ try {
254
+ const payload = JSON.parse(message.toString())
255
+
256
+ // Emit typed events based on message type
257
+ if (messageType === 'events') {
258
+ this.emit('events', /** @type {YotoEventsMessage} */ (payload))
259
+ } else if (messageType === 'status') {
260
+ this.emit('status', /** @type {YotoStatusMessage} */ (payload))
261
+ } else if (messageType === 'response') {
262
+ this.emit('response', /** @type {YotoResponseMessage} */ (payload))
263
+ }
264
+ } catch (err) {
265
+ const error = /** @type {Error} */ (err)
266
+ this.emit('error', new Error(`Failed to parse message from ${topic}: ${error.message}`))
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Connect to MQTT broker
272
+ * @returns {Promise<void>}
273
+ */
274
+ async connect () {
275
+ return new Promise((resolve, reject) => {
276
+ if (this.connected) {
277
+ resolve()
278
+ return
279
+ }
280
+
281
+ const onConnect = () => {
282
+ cleanup()
283
+ resolve()
284
+ }
285
+
286
+ const onError = (/** @type {Error} */ error) => {
287
+ cleanup()
288
+ reject(error)
289
+ }
290
+
291
+ const cleanup = () => {
292
+ this.mqttClient.off('connect', onConnect)
293
+ this.mqttClient.off('error', onError)
294
+ }
295
+
296
+ this.mqttClient.once('connect', onConnect)
297
+ this.mqttClient.once('error', onError)
298
+
299
+ // MQTT client should already be connecting from factory
300
+ // If not connected, it will connect automatically
301
+ })
302
+ }
303
+
304
+ /**
305
+ * Disconnect from MQTT broker
306
+ * @returns {Promise<void>}
307
+ */
308
+ async disconnect () {
309
+ return new Promise((resolve) => {
310
+ if (!this.connected && !this.mqttClient.reconnecting) {
311
+ resolve()
312
+ return
313
+ }
314
+
315
+ const onClose = () => {
316
+ this.mqttClient.off('close', onClose)
317
+ resolve()
318
+ }
319
+
320
+ this.mqttClient.once('close', onClose)
321
+ this.mqttClient.end()
322
+ })
323
+ }
324
+
325
+ /**
326
+ * Publish a message to a topic
327
+ * @param {string} topic - MQTT topic
328
+ * @param {Object | string} payload - Message payload (will be JSON stringified if object)
329
+ * @returns {Promise<void>}
330
+ */
331
+ async #publish (topic, payload) {
332
+ return new Promise((resolve, reject) => {
333
+ const message = typeof payload === 'string' ? payload : JSON.stringify(payload)
334
+
335
+ this.mqttClient.publish(topic, message, (err) => {
336
+ if (err) {
337
+ reject(new Error(`Failed to publish to ${topic}: ${err.message}`))
338
+ } else {
339
+ resolve()
340
+ }
341
+ })
342
+ })
343
+ }
344
+
345
+ // Command methods
346
+
347
+ /**
348
+ * Request current events from device
349
+ * @returns {Promise<void>}
350
+ */
351
+ async requestEvents () {
352
+ const topic = getEventsRequestTopic(this.deviceId)
353
+ return this.#publish(topic, '')
354
+ }
355
+
356
+ /**
357
+ * Request current status from device
358
+ * @returns {Promise<void>}
359
+ */
360
+ async requestStatus () {
361
+ const topic = getStatusRequestTopic(this.deviceId)
362
+ return this.#publish(topic, '')
363
+ }
364
+
365
+ /**
366
+ * Set device volume
367
+ * @param {number} volume - Volume level [0-100]
368
+ * @returns {Promise<void>}
369
+ */
370
+ async setVolume (volume) {
371
+ const command = commands.volume(volume)
372
+ const topic = getVolumeSetTopic(this.deviceId)
373
+ return this.#publish(topic, command)
374
+ }
375
+
376
+ /**
377
+ * Set ambient light color
378
+ * @param {number} r - Red intensity [0-255]
379
+ * @param {number} g - Green intensity [0-255]
380
+ * @param {number} b - Blue intensity [0-255]
381
+ * @returns {Promise<void>}
382
+ */
383
+ async setAmbient (r, g, b) {
384
+ const command = commands.ambient(r, g, b)
385
+ const topic = getAmbientsSetTopic(this.deviceId)
386
+ return this.#publish(topic, command)
387
+ }
388
+
389
+ /**
390
+ * Set ambient light color from hex
391
+ * @param {string} hexColor - Hex color string (e.g., "#FF0000")
392
+ * @returns {Promise<void>}
393
+ */
394
+ async setAmbientHex (hexColor) {
395
+ const command = commands.ambientFromHex(hexColor)
396
+ const topic = getAmbientsSetTopic(this.deviceId)
397
+ return this.#publish(topic, command)
398
+ }
399
+
400
+ /**
401
+ * Set sleep timer
402
+ * @param {number} seconds - Timer duration in seconds (0 to disable)
403
+ * @returns {Promise<void>}
404
+ */
405
+ async setSleepTimer (seconds) {
406
+ const command = commands.sleepTimer(seconds)
407
+ const topic = getSleepTimerSetTopic(this.deviceId)
408
+ return this.#publish(topic, command)
409
+ }
410
+
411
+ /**
412
+ * Reboot device
413
+ * @returns {Promise<void>}
414
+ */
415
+ async reboot () {
416
+ const topic = getRebootTopic(this.deviceId)
417
+ return this.#publish(topic, '')
418
+ }
419
+
420
+ /**
421
+ * Start card playback
422
+ * @param {Object} options - Card start options
423
+ * @param {string} options.uri - Card URI (e.g., "https://yoto.io/<cardID>")
424
+ * @param {string} [options.chapterKey] - Chapter to start from
425
+ * @param {string} [options.trackKey] - Track to start from
426
+ * @param {number} [options.secondsIn] - Playback start offset in seconds
427
+ * @param {number} [options.cutOff] - Playback stop offset in seconds
428
+ * @param {boolean} [options.anyButtonStop] - Whether button press stops playback
429
+ * @returns {Promise<void>}
430
+ */
431
+ async startCard (options) {
432
+ const command = commands.cardStart(options)
433
+ const topic = getCardStartTopic(this.deviceId)
434
+ return this.#publish(topic, command)
435
+ }
436
+
437
+ /**
438
+ * Stop card playback
439
+ * @returns {Promise<void>}
440
+ */
441
+ async stopCard () {
442
+ const topic = getCardStopTopic(this.deviceId)
443
+ return this.#publish(topic, '')
444
+ }
445
+
446
+ /**
447
+ * Pause card playback
448
+ * @returns {Promise<void>}
449
+ */
450
+ async pauseCard () {
451
+ const topic = getCardPauseTopic(this.deviceId)
452
+ return this.#publish(topic, '')
453
+ }
454
+
455
+ /**
456
+ * Resume card playback
457
+ * @returns {Promise<void>}
458
+ */
459
+ async resumeCard () {
460
+ const topic = getCardResumeTopic(this.deviceId)
461
+ return this.#publish(topic, '')
462
+ }
463
+
464
+ /**
465
+ * Turn Bluetooth on
466
+ * @param {Object} [options] - Bluetooth options
467
+ * @param {string} [options.action] - Bluetooth action
468
+ * @param {boolean | string} [options.mode] - Bluetooth mode
469
+ * @param {number} [options.rssi] - RSSI threshold
470
+ * @param {string} [options.name] - Target device name
471
+ * @param {string} [options.mac] - Target device MAC
472
+ * @returns {Promise<void>}
473
+ */
474
+ async bluetoothOn (options) {
475
+ const command = commands.bluetoothOn(options)
476
+ const topic = getBluetoothOnTopic(this.deviceId)
477
+ return this.#publish(topic, command)
478
+ }
479
+
480
+ /**
481
+ * Turn Bluetooth off
482
+ * @returns {Promise<void>}
483
+ */
484
+ async bluetoothOff () {
485
+ const topic = getBluetoothOffTopic(this.deviceId)
486
+ return this.#publish(topic, '')
487
+ }
488
+
489
+ /**
490
+ * Enable Bluetooth speaker mode
491
+ * @returns {Promise<void>}
492
+ */
493
+ async bluetoothSpeakerMode () {
494
+ const command = commands.bluetoothSpeaker()
495
+ const topic = getBluetoothOnTopic(this.deviceId)
496
+ return this.#publish(topic, command)
497
+ }
498
+
499
+ /**
500
+ * Enable Bluetooth audio source mode
501
+ * @returns {Promise<void>}
502
+ */
503
+ async bluetoothAudioSourceMode () {
504
+ const command = commands.bluetoothAudioSource()
505
+ const topic = getBluetoothOnTopic(this.deviceId)
506
+ return this.#publish(topic, command)
507
+ }
508
+
509
+ /**
510
+ * Delete all Bluetooth bonds
511
+ * @returns {Promise<void>}
512
+ */
513
+ async bluetoothDeleteBonds () {
514
+ const topic = getBluetoothDeleteBondsTopic(this.deviceId)
515
+ return this.#publish(topic, '')
516
+ }
517
+
518
+ /**
519
+ * Connect to Bluetooth device
520
+ * @returns {Promise<void>}
521
+ */
522
+ async bluetoothConnect () {
523
+ const topic = getBluetoothConnectTopic(this.deviceId)
524
+ return this.#publish(topic, '')
525
+ }
526
+
527
+ /**
528
+ * Disconnect Bluetooth device
529
+ * @returns {Promise<void>}
530
+ */
531
+ async bluetoothDisconnect () {
532
+ const topic = getBluetoothDisconnectTopic(this.deviceId)
533
+ return this.#publish(topic, '')
534
+ }
535
+
536
+ /**
537
+ * Get Bluetooth state
538
+ * @returns {Promise<void>}
539
+ */
540
+ async bluetoothGetState () {
541
+ const topic = getBluetoothStateTopic(this.deviceId)
542
+ return this.#publish(topic, '')
543
+ }
544
+
545
+ /**
546
+ * Preview display icon
547
+ * @param {Object} options - Display preview options
548
+ * @param {string} options.uri - Icon URI
549
+ * @param {number} options.timeout - Display duration in seconds
550
+ * @param {boolean} options.animated - Whether icon is animated
551
+ * @returns {Promise<void>}
552
+ */
553
+ async displayPreview (options) {
554
+ const command = commands.displayPreview(options)
555
+ const topic = getDisplayPreviewTopic(this.deviceId)
556
+ return this.#publish(topic, command)
557
+ }
558
+ }