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