homebridge-yoto 0.0.27 → 0.0.31

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/lib/yotoMqtt.js DELETED
@@ -1,570 +0,0 @@
1
- /**
2
- * @fileoverview MQTT client for real-time Yoto device communication
3
- */
4
-
5
- /** @import { Logger } from 'homebridge' */
6
- /** @import { YotoDeviceStatus, YotoPlaybackEvents, MqttVolumeCommand, MqttAmbientCommand, MqttSleepTimerCommand, MqttCardStartCommand, MqttCommandResponse } from './types.js' */
7
-
8
- import mqtt from 'mqtt'
9
- import { EventEmitter } from 'events'
10
- import {
11
- YOTO_MQTT_BROKER_URL,
12
- YOTO_MQTT_AUTH_NAME,
13
- MQTT_RECONNECT_PERIOD,
14
- MQTT_CONNECT_TIMEOUT,
15
- MQTT_TOPIC_DATA_STATUS,
16
- MQTT_TOPIC_DATA_EVENTS,
17
- MQTT_TOPIC_RESPONSE,
18
- MQTT_TOPIC_COMMAND_STATUS_REQUEST,
19
- MQTT_TOPIC_COMMAND_EVENTS_REQUEST,
20
- MQTT_TOPIC_COMMAND_VOLUME_SET,
21
- MQTT_TOPIC_COMMAND_CARD_START,
22
- MQTT_TOPIC_COMMAND_CARD_STOP,
23
- MQTT_TOPIC_COMMAND_CARD_PAUSE,
24
- MQTT_TOPIC_COMMAND_CARD_RESUME,
25
- MQTT_TOPIC_COMMAND_SLEEP_TIMER,
26
- MQTT_TOPIC_COMMAND_AMBIENTS_SET,
27
- ERROR_MESSAGES,
28
- LOG_PREFIX,
29
- INITIAL_STATUS_REQUEST_DELAY
30
- } from './constants.js'
31
-
32
- /**
33
- * MQTT client for Yoto device communication
34
- * @extends EventEmitter
35
- */
36
- export class YotoMqtt extends EventEmitter {
37
- /**
38
- * @param {Logger} log - Homebridge logger
39
- * @param {Object} [options] - MQTT options
40
- * @param {string} [options.brokerUrl] - MQTT broker URL
41
- */
42
- constructor (log, options = {}) {
43
- super()
44
- this.log = log
45
- this.brokerUrl = options.brokerUrl || YOTO_MQTT_BROKER_URL
46
- this.client = null
47
- this.connected = false
48
- this.subscribedDevices = new Set()
49
- this.deviceCallbacks = new Map()
50
- this.reconnectAttempts = 0
51
- this.maxReconnectAttempts = 10
52
- this.reconnectDelay = MQTT_RECONNECT_PERIOD
53
- }
54
-
55
- /**
56
- * Connect to MQTT broker
57
- * @param {string} accessToken - Yoto access token for authentication
58
- * @param {string} deviceId - Device ID for MQTT client identification
59
- * @returns {Promise<void>}
60
- */
61
- async connect (accessToken, deviceId) {
62
- if (this.client) {
63
- this.log.debug(LOG_PREFIX.MQTT, 'Already connected, disconnecting first...')
64
- await this.disconnect()
65
- }
66
-
67
- return new Promise((resolve, reject) => {
68
- this.log.debug(LOG_PREFIX.MQTT, `Connecting to ${this.brokerUrl}...`)
69
-
70
- const clientId = `DASH${deviceId}`
71
- const username = `${deviceId}?x-amz-customauthorizer-name=${YOTO_MQTT_AUTH_NAME}`
72
-
73
- this.log.debug(LOG_PREFIX.MQTT, `Connecting with client ID: ${clientId}`)
74
-
75
- this.client = mqtt.connect(this.brokerUrl, {
76
- keepalive: 300,
77
- port: 443,
78
- protocol: 'wss',
79
- username,
80
- password: accessToken,
81
- reconnectPeriod: 0, // Disable auto-reconnect - we'll handle reconnection manually
82
- connectTimeout: MQTT_CONNECT_TIMEOUT,
83
- clientId,
84
- ALPNProtocols: ['x-amzn-mqtt-ca']
85
- })
86
-
87
- this.client.on('connect', () => {
88
- this.connected = true
89
- this.reconnectAttempts = 0
90
- this.reconnectDelay = MQTT_RECONNECT_PERIOD
91
- this.log.info(LOG_PREFIX.MQTT, '✓ Connected to MQTT broker')
92
-
93
- // Emit connected event
94
- this.emit('connected')
95
-
96
- // Resubscribe to all devices after reconnection
97
- this.resubscribeDevices()
98
-
99
- resolve()
100
- })
101
-
102
- this.client.on('error', (error) => {
103
- this.log.error(LOG_PREFIX.MQTT, 'Connection error:', error)
104
- this.log.error(LOG_PREFIX.MQTT, 'Error message:', error.message)
105
- const errorWithCode = /** @type {any} */ (error)
106
- this.log.error(LOG_PREFIX.MQTT, 'Error code:', errorWithCode.code)
107
- this.log.error(LOG_PREFIX.MQTT, 'Error stack:', error.stack)
108
- if (errorWithCode.code) {
109
- this.log.error(LOG_PREFIX.MQTT, `AWS IoT error code: ${errorWithCode.code}`)
110
- }
111
- if (!this.connected) {
112
- reject(error)
113
- }
114
- })
115
-
116
- this.client.on('close', () => {
117
- const wasConnected = this.connected
118
- this.connected = false
119
-
120
- // Emit disconnected event
121
- this.emit('disconnected')
122
-
123
- if (wasConnected) {
124
- this.log.info(LOG_PREFIX.MQTT, ERROR_MESSAGES.MQTT_DISCONNECTED)
125
- this.handleReconnect()
126
- } else {
127
- this.log.error(LOG_PREFIX.MQTT, 'Connection closed before establishing connection')
128
- }
129
- })
130
-
131
- this.client.on('reconnect', () => {
132
- this.reconnectAttempts++
133
- this.log.debug(LOG_PREFIX.MQTT, `Reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}`)
134
-
135
- if (this.reconnectAttempts >= this.maxReconnectAttempts) {
136
- this.log.error(LOG_PREFIX.MQTT, 'Max reconnection attempts reached, stopping reconnection')
137
- if (this.client) {
138
- this.client.end(true)
139
- }
140
- }
141
- })
142
-
143
- this.client.on('offline', () => {
144
- this.connected = false
145
- this.log.debug(LOG_PREFIX.MQTT, 'MQTT client offline - connection failed or lost')
146
-
147
- // Emit offline event
148
- this.emit('offline')
149
- })
150
-
151
- this.client.on('end', () => {
152
- this.log.debug(LOG_PREFIX.MQTT, 'MQTT client ended')
153
- })
154
-
155
- this.client.on('disconnect', (packet) => {
156
- this.log.debug(LOG_PREFIX.MQTT, 'MQTT client disconnected:', packet)
157
- })
158
-
159
- this.client.on('packetreceive', (packet) => {
160
- this.log.debug(LOG_PREFIX.MQTT, 'Packet received:', packet.cmd)
161
- })
162
-
163
- this.client.on('packetsend', (packet) => {
164
- this.log.debug(LOG_PREFIX.MQTT, 'Packet sent:', packet.cmd)
165
- })
166
-
167
- this.client.on('message', (topic, message) => {
168
- this.handleMessage(topic, message)
169
- })
170
-
171
- // Timeout if connection takes too long
172
- setTimeout(() => {
173
- if (!this.connected) {
174
- reject(new Error(ERROR_MESSAGES.MQTT_CONNECTION_FAILED))
175
- }
176
- }, MQTT_CONNECT_TIMEOUT)
177
- })
178
- }
179
-
180
- /**
181
- * Disconnect from MQTT broker
182
- * @returns {Promise<void>}
183
- */
184
- async disconnect () {
185
- if (!this.client) {
186
- return
187
- }
188
-
189
- return new Promise((resolve) => {
190
- this.log.debug(LOG_PREFIX.MQTT, 'Disconnecting from MQTT broker...')
191
-
192
- if (this.client) {
193
- this.client.end(false, {}, () => {
194
- this.connected = false
195
- this.client = null
196
- this.subscribedDevices.clear()
197
- this.log.debug(LOG_PREFIX.MQTT, 'Disconnected')
198
- resolve()
199
- })
200
- } else {
201
- resolve()
202
- }
203
- })
204
- }
205
-
206
- /**
207
- * Handle reconnection with exponential backoff
208
- */
209
- handleReconnect () {
210
- if (this.reconnectAttempts >= this.maxReconnectAttempts) {
211
- this.log.error(LOG_PREFIX.MQTT, 'Max reconnection attempts reached')
212
- return
213
- }
214
-
215
- // Exponential backoff with jitter
216
- this.reconnectDelay = Math.min(
217
- MQTT_RECONNECT_PERIOD * Math.pow(2, this.reconnectAttempts),
218
- 60000 // Max 60 seconds
219
- )
220
-
221
- const jitter = Math.random() * 1000
222
- const delay = this.reconnectDelay + jitter
223
-
224
- this.log.debug(LOG_PREFIX.MQTT, `Will attempt reconnection in ${Math.round(delay / 1000)}s`)
225
- }
226
-
227
- /**
228
- * Resubscribe to all device topics after reconnection
229
- */
230
- async resubscribeDevices () {
231
- if (this.subscribedDevices.size === 0) {
232
- return
233
- }
234
-
235
- this.log.debug(LOG_PREFIX.MQTT, `Resubscribing to ${this.subscribedDevices.size} device(s)...`)
236
-
237
- for (const deviceId of this.subscribedDevices) {
238
- const callbacks = this.deviceCallbacks.get(deviceId)
239
- if (callbacks) {
240
- try {
241
- // Clear from set temporarily to allow resubscription
242
- this.subscribedDevices.delete(deviceId)
243
- await this.subscribeToDevice(deviceId, callbacks)
244
- } catch (error) {
245
- this.log.error(LOG_PREFIX.MQTT, `Failed to resubscribe to device ${deviceId}:`, error)
246
- }
247
- }
248
- }
249
- }
250
-
251
- /**
252
- * Subscribe to device topics
253
- * @param {string} deviceId - Device ID
254
- * @param {Object} callbacks - Callback functions
255
- * @param {(status: YotoDeviceStatus) => void} [callbacks.onStatus] - Status update callback
256
- * @param {(events: YotoPlaybackEvents) => void} [callbacks.onEvents] - Events update callback
257
- * @param {(response: MqttCommandResponse) => void} [callbacks.onResponse] - Command response callback
258
- * @returns {Promise<void>}
259
- */
260
- async subscribeToDevice (deviceId, callbacks) {
261
- if (!this.client || !this.connected) {
262
- throw new Error('MQTT client not connected')
263
- }
264
-
265
- if (this.subscribedDevices.has(deviceId)) {
266
- this.log.debug(LOG_PREFIX.MQTT, `Already subscribed to device ${deviceId}`)
267
- return
268
- }
269
-
270
- this.log.debug(LOG_PREFIX.MQTT, `Subscribing to device ${deviceId}...`)
271
-
272
- const topics = [
273
- this.buildTopic(MQTT_TOPIC_DATA_STATUS, deviceId),
274
- this.buildTopic(MQTT_TOPIC_DATA_EVENTS, deviceId),
275
- this.buildTopic(MQTT_TOPIC_RESPONSE, deviceId)
276
- ]
277
-
278
- return new Promise((resolve, reject) => {
279
- if (!this.client) {
280
- reject(new Error('MQTT client not available'))
281
- return
282
- }
283
-
284
- this.client.subscribe(topics, (error) => {
285
- if (error) {
286
- this.log.error(LOG_PREFIX.MQTT, `Failed to subscribe to device ${deviceId}:`, error)
287
- reject(error)
288
- return
289
- }
290
-
291
- this.subscribedDevices.add(deviceId)
292
- this.deviceCallbacks.set(deviceId, callbacks)
293
- this.log.debug(LOG_PREFIX.MQTT, `✓ Subscribed to device ${deviceId}`)
294
-
295
- // Request initial status after a short delay
296
- setTimeout(() => {
297
- this.requestStatus(deviceId).catch(err => {
298
- this.log.debug(LOG_PREFIX.MQTT, `Failed to request initial status for ${deviceId}:`, err)
299
- })
300
- this.requestEvents(deviceId).catch(err => {
301
- this.log.debug(LOG_PREFIX.MQTT, `Failed to request initial events for ${deviceId}:`, err)
302
- })
303
- }, INITIAL_STATUS_REQUEST_DELAY)
304
-
305
- resolve()
306
- })
307
- })
308
- }
309
-
310
- /**
311
- * Unsubscribe from device topics
312
- * @param {string} deviceId - Device ID
313
- * @returns {Promise<void>}
314
- */
315
- async unsubscribeFromDevice (deviceId) {
316
- if (!this.client || !this.subscribedDevices.has(deviceId)) {
317
- return
318
- }
319
-
320
- this.log.debug(LOG_PREFIX.MQTT, `Unsubscribing from device ${deviceId}...`)
321
-
322
- const topics = [
323
- this.buildTopic(MQTT_TOPIC_DATA_STATUS, deviceId),
324
- this.buildTopic(MQTT_TOPIC_DATA_EVENTS, deviceId),
325
- this.buildTopic(MQTT_TOPIC_RESPONSE, deviceId)
326
- ]
327
-
328
- return new Promise((resolve, reject) => {
329
- if (!this.client) {
330
- reject(new Error('MQTT client not available'))
331
- return
332
- }
333
-
334
- this.client.unsubscribe(topics, (error) => {
335
- if (error) {
336
- this.log.error(LOG_PREFIX.MQTT, `Failed to unsubscribe from device ${deviceId}:`, error)
337
- reject(error)
338
- return
339
- }
340
-
341
- this.subscribedDevices.delete(deviceId)
342
- this.deviceCallbacks.delete(deviceId)
343
- this.log.debug(LOG_PREFIX.MQTT, `✓ Unsubscribed from device ${deviceId}`)
344
- resolve()
345
- })
346
- })
347
- }
348
-
349
- /**
350
- * Handle incoming MQTT message
351
- * @param {string} topic - Message topic
352
- * @param {Buffer} message - Message payload
353
- */
354
- handleMessage (topic, message) {
355
- try {
356
- const payload = JSON.parse(message.toString())
357
- const deviceId = this.extractDeviceId(topic)
358
-
359
- if (!deviceId) {
360
- this.log.debug(LOG_PREFIX.MQTT, `Could not extract device ID from topic: ${topic}`)
361
- return
362
- }
363
-
364
- const callbacks = this.deviceCallbacks.get(deviceId)
365
- if (!callbacks) {
366
- this.log.debug(LOG_PREFIX.MQTT, `No callbacks registered for device ${deviceId}`)
367
- return
368
- }
369
-
370
- if (topic.includes('/status')) {
371
- this.log.debug(LOG_PREFIX.MQTT, `Status update for ${deviceId}`)
372
- callbacks.onStatus?.(payload)
373
- } else if (topic.includes('/events')) {
374
- this.log.debug(LOG_PREFIX.MQTT, `Events update for ${deviceId}`)
375
- callbacks.onEvents?.(payload)
376
- } else if (topic.includes('/response')) {
377
- this.log.debug(LOG_PREFIX.MQTT, `Command response for ${deviceId}`)
378
- callbacks.onResponse?.(payload)
379
- }
380
- } catch (error) {
381
- this.log.error(LOG_PREFIX.MQTT, 'Error handling message:', error)
382
- this.log.debug(LOG_PREFIX.MQTT, 'Failed message topic:', topic)
383
- this.log.debug(LOG_PREFIX.MQTT, 'Failed message payload:', message.toString())
384
- }
385
- }
386
-
387
- /**
388
- * Publish a command to device
389
- * @param {string} topic - Command topic
390
- * @param {any} [payload] - Command payload
391
- * @returns {Promise<void>}
392
- */
393
- async publish (topic, payload = {}) {
394
- if (!this.client || !this.connected) {
395
- throw new Error('MQTT client not connected')
396
- }
397
-
398
- return new Promise((resolve, reject) => {
399
- if (!this.client) {
400
- reject(new Error('MQTT client not available'))
401
- return
402
- }
403
-
404
- const message = JSON.stringify(payload)
405
- this.log.debug(LOG_PREFIX.MQTT, `Publishing to ${topic}:`, message)
406
-
407
- // Add timeout for publish operation
408
- const timeout = setTimeout(() => {
409
- reject(new Error('MQTT publish timeout'))
410
- }, 5000)
411
-
412
- this.client.publish(topic, message, { qos: 0 }, (error) => {
413
- clearTimeout(timeout)
414
-
415
- if (error) {
416
- this.log.error(LOG_PREFIX.MQTT, `Failed to publish to ${topic}:`, error)
417
- reject(error)
418
- } else {
419
- resolve()
420
- }
421
- })
422
- })
423
- }
424
-
425
- /**
426
- * Request current device status
427
- * @param {string} deviceId - Device ID
428
- * @returns {Promise<void>}
429
- */
430
- async requestStatus (deviceId) {
431
- const topic = this.buildTopic(MQTT_TOPIC_COMMAND_STATUS_REQUEST, deviceId)
432
- await this.publish(topic, {})
433
- }
434
-
435
- /**
436
- * Request current playback events
437
- * @param {string} deviceId - Device ID
438
- * @returns {Promise<void>}
439
- */
440
- async requestEvents (deviceId) {
441
- const topic = this.buildTopic(MQTT_TOPIC_COMMAND_EVENTS_REQUEST, deviceId)
442
- await this.publish(topic, {})
443
- }
444
-
445
- /**
446
- * Set device volume
447
- * @param {string} deviceId - Device ID
448
- * @param {number} volume - Volume level (0-100)
449
- * @returns {Promise<void>}
450
- */
451
- async setVolume (deviceId, volume) {
452
- const topic = this.buildTopic(MQTT_TOPIC_COMMAND_VOLUME_SET, deviceId)
453
- /** @type {MqttVolumeCommand} */
454
- const payload = { volume: Math.round(volume) }
455
- await this.publish(topic, payload)
456
- }
457
-
458
- /**
459
- * Start playing a card
460
- * @param {string} deviceId - Device ID
461
- * @param {string} cardUri - Card URI
462
- * @param {Object} [options] - Playback options
463
- * @param {string} [options.chapterKey] - Chapter to start from
464
- * @param {string} [options.trackKey] - Track to start from
465
- * @param {number} [options.secondsIn] - Start offset in seconds
466
- * @param {number} [options.cutOff] - Stop offset in seconds
467
- * @param {boolean} [options.anyButtonStop] - Any button stops playback
468
- * @returns {Promise<void>}
469
- */
470
- async startCard (deviceId, cardUri, options = {}) {
471
- const topic = this.buildTopic(MQTT_TOPIC_COMMAND_CARD_START, deviceId)
472
- /** @type {MqttCardStartCommand} */
473
- const payload = {
474
- uri: cardUri
475
- }
476
- if (options.chapterKey !== undefined) payload.chapterKey = options.chapterKey
477
- if (options.trackKey !== undefined) payload.trackKey = options.trackKey
478
- if (options.secondsIn !== undefined) payload.secondsIn = options.secondsIn
479
- if (options.cutOff !== undefined) payload.cutOff = options.cutOff
480
- if (options.anyButtonStop !== undefined) payload.anyButtonStop = options.anyButtonStop
481
- await this.publish(topic, payload)
482
- }
483
-
484
- /**
485
- * Pause card playback
486
- * @param {string} deviceId - Device ID
487
- * @returns {Promise<void>}
488
- */
489
- async pauseCard (deviceId) {
490
- const topic = this.buildTopic(MQTT_TOPIC_COMMAND_CARD_PAUSE, deviceId)
491
- await this.publish(topic, {})
492
- }
493
-
494
- /**
495
- * Resume card playback
496
- * @param {string} deviceId - Device ID
497
- * @returns {Promise<void>}
498
- */
499
- async resumeCard (deviceId) {
500
- const topic = this.buildTopic(MQTT_TOPIC_COMMAND_CARD_RESUME, deviceId)
501
- await this.publish(topic, {})
502
- }
503
-
504
- /**
505
- * Stop card playback
506
- * @param {string} deviceId - Device ID
507
- * @returns {Promise<void>}
508
- */
509
- async stopCard (deviceId) {
510
- const topic = this.buildTopic(MQTT_TOPIC_COMMAND_CARD_STOP, deviceId)
511
- await this.publish(topic, {})
512
- }
513
-
514
- /**
515
- * Set sleep timer
516
- * @param {string} deviceId - Device ID
517
- * @param {number} seconds - Duration in seconds (0 to disable)
518
- * @returns {Promise<void>}
519
- */
520
- async setSleepTimer (deviceId, seconds) {
521
- const topic = this.buildTopic(MQTT_TOPIC_COMMAND_SLEEP_TIMER, deviceId)
522
- /** @type {MqttSleepTimerCommand} */
523
- const payload = { seconds }
524
- await this.publish(topic, payload)
525
- }
526
-
527
- /**
528
- * Set ambient light color
529
- * @param {string} deviceId - Device ID
530
- * @param {number} r - Red intensity (0-255)
531
- * @param {number} g - Green intensity (0-255)
532
- * @param {number} b - Blue intensity (0-255)
533
- * @returns {Promise<void>}
534
- */
535
- async setAmbientLight (deviceId, r, g, b) {
536
- const topic = this.buildTopic(MQTT_TOPIC_COMMAND_AMBIENTS_SET, deviceId)
537
- /** @type {MqttAmbientCommand} */
538
- const payload = { r, g, b }
539
- await this.publish(topic, payload)
540
- }
541
-
542
- /**
543
- * Build topic string with device ID
544
- * @param {string} template - Topic template
545
- * @param {string} deviceId - Device ID
546
- * @returns {string}
547
- */
548
- buildTopic (template, deviceId) {
549
- return template.replace('{deviceId}', deviceId)
550
- }
551
-
552
- /**
553
- * Extract device ID from topic
554
- * @param {string} topic - Topic string
555
- * @returns {string | null}
556
- */
557
- extractDeviceId (topic) {
558
- // Match both /device/{id}/ and device/{id}/ patterns
559
- const match = topic.match(/\/?device\/([^/]+)\//)
560
- return match?.[1] ?? null
561
- }
562
-
563
- /**
564
- * Check if connected to MQTT broker
565
- * @returns {boolean}
566
- */
567
- isConnected () {
568
- return this.connected && this.client !== null
569
- }
570
- }