homebridge-yoto 0.0.39 → 0.0.41

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/platform.js CHANGED
@@ -5,11 +5,21 @@
5
5
  /** @import { API, DynamicPlatformPlugin, Logger, PlatformAccessory, PlatformConfig, Service, Characteristic } from 'homebridge' */
6
6
  /** @import { YotoDevice } from 'yoto-nodejs-client/lib/api-endpoints/devices.js' */
7
7
  /** @import { YotoDeviceModel } from 'yoto-nodejs-client' */
8
+ /** @import { PlaybackAccessoryConfig } from './service-config.js' */
9
+ /** @import { CardControlConfig } from './card-controls.js' */
8
10
 
9
11
  /**
10
12
  * Context stored in PlatformAccessory for Yoto devices
11
13
  * @typedef {Object} YotoAccessoryContext
12
14
  * @property {YotoDevice} device - Device metadata from Yoto API
15
+ * @property {'device'} [type] - Accessory type marker
16
+ */
17
+
18
+ /**
19
+ * Context stored in PlatformAccessory for card control accessories
20
+ * @typedef {Object} YotoCardAccessoryContext
21
+ * @property {CardControlConfig} cardControl - Card control configuration
22
+ * @property {'card-control'} type - Accessory type marker
13
23
  */
14
24
 
15
25
  import { readFile, writeFile } from 'node:fs/promises'
@@ -21,7 +31,11 @@ import {
21
31
  DEFAULT_CLIENT_ID,
22
32
  } from './settings.js'
23
33
  import { YotoPlayerAccessory } from './accessory.js'
34
+ import { YotoSpeakerAccessory } from './speaker-accessory.js'
35
+ import { YotoCardControlAccessory } from './card-control-accessory.js'
24
36
  import { sanitizeName } from './sanitize-name.js'
37
+ import { getPlaybackAccessoryConfig } from './service-config.js'
38
+ import { getCardControlConfigs } from './card-controls.js'
25
39
 
26
40
  /**
27
41
  * Yoto Platform implementation
@@ -35,8 +49,13 @@ export class YotoPlatform {
35
49
  /** @type {API} */ api
36
50
  /** @type {typeof Service} */ Service
37
51
  /** @type {typeof Characteristic} */ Characteristic
52
+ /** @type {PlaybackAccessoryConfig} */ playbackAccessoryConfig
38
53
  /** @type {Map<string, PlatformAccessory<YotoAccessoryContext>>} */ accessories = new Map()
54
+ /** @type {Map<string, PlatformAccessory<YotoAccessoryContext>>} */ speakerAccessories = new Map()
55
+ /** @type {Map<string, PlatformAccessory<YotoCardAccessoryContext>>} */ cardAccessories = new Map()
39
56
  /** @type {Map<string, YotoPlayerAccessory>} */ accessoryHandlers = new Map()
57
+ /** @type {Map<string, YotoSpeakerAccessory>} */ speakerAccessoryHandlers = new Map()
58
+ /** @type {Map<string, YotoCardControlAccessory>} */ cardAccessoryHandlers = new Map()
40
59
  /** @type {YotoAccount | null} */ yotoAccount = null
41
60
  /** @type {string} */ sessionId = randomUUID()
42
61
 
@@ -51,6 +70,7 @@ export class YotoPlatform {
51
70
  this.api = api
52
71
  this.Service = api.hap.Service
53
72
  this.Characteristic = api.hap.Characteristic
73
+ this.playbackAccessoryConfig = getPlaybackAccessoryConfig(config)
54
74
 
55
75
  log.debug('Finished initializing platform:', config.name)
56
76
 
@@ -107,12 +127,20 @@ export class YotoPlatform {
107
127
  }
108
128
  })
109
129
 
130
+ const formatError = (/** @type {unknown} */ error) => (
131
+ error instanceof Error ? (error.stack || error.message) : String(error)
132
+ )
133
+
110
134
  // Listen to account-level events
111
135
  this.yotoAccount.on('error', ({ error, context }) => {
136
+ const details = formatError(error)
112
137
  if (context.deviceId) {
113
- log.error(`Device error [${context.deviceId} ${context.operation} ${context.source}]:`, error.message)
138
+ const label = this.formatDeviceLabel(context.deviceId)
139
+ log.error(`Device error [${label} ${context.operation} ${context.source}]:`, details)
140
+ log.debug('Device error context:', context)
114
141
  } else {
115
- log.error('Account error:', error.message)
142
+ log.error('Account error:', details)
143
+ log.debug('Account error context:', context)
116
144
  }
117
145
  })
118
146
 
@@ -141,9 +169,22 @@ export class YotoPlatform {
141
169
  * @param {PlatformAccessory} accessory - Cached accessory
142
170
  */
143
171
  configureAccessory (accessory) {
144
- const { log, accessories } = this
172
+ const { log, accessories, cardAccessories } = this
145
173
  log.debug('Loading accessory from cache:', accessory.displayName)
146
174
 
175
+ const context = accessory.context
176
+ const record = context && typeof context === 'object'
177
+ ? /** @type {Record<string, unknown>} */ (context)
178
+ : null
179
+ const accessoryType = record && typeof record['type'] === 'string'
180
+ ? record['type']
181
+ : undefined
182
+
183
+ if (accessoryType === 'card-control' || record?.['cardControl']) {
184
+ cardAccessories.set(accessory.UUID, /** @type {PlatformAccessory<YotoCardAccessoryContext>} */ (accessory))
185
+ return
186
+ }
187
+
147
188
  // Add to our tracking map (cast to our typed version)
148
189
  accessories.set(accessory.UUID, /** @type {PlatformAccessory<YotoAccessoryContext>} */ (accessory))
149
190
  }
@@ -164,7 +205,8 @@ export class YotoPlatform {
164
205
  this.yotoAccount.on('deviceAdded', async ({ deviceId }) => {
165
206
  const deviceModel = this.yotoAccount?.getDevice(deviceId)
166
207
  if (!deviceModel) {
167
- this.log.warn(`Device added but no model found for ${deviceId}`)
208
+ const label = this.formatDeviceLabel(deviceId)
209
+ this.log.warn(`Device added but no model found for ${label}`)
168
210
  return
169
211
  }
170
212
 
@@ -174,29 +216,23 @@ export class YotoPlatform {
174
216
  })
175
217
 
176
218
  this.yotoAccount.on('deviceRemoved', ({ deviceId }) => {
177
- this.log.debug(`Device removed: ${deviceId}`)
219
+ const label = this.formatDeviceLabel(deviceId)
220
+ this.log.debug(`Device removed: ${label}`)
178
221
  this.removeStaleAccessories()
179
222
  })
180
223
 
181
224
  this.yotoAccount.on('online', ({ deviceId, metadata }) => {
225
+ const label = this.formatDeviceLabel(deviceId)
182
226
  const reason = metadata?.reason ? ` (${metadata.reason})` : ''
183
- this.log.info(`Device online: ${deviceId}${reason}`)
227
+ this.log.info(`Device online: ${label}${reason}`)
184
228
  })
185
229
 
186
230
  this.yotoAccount.on('offline', ({ deviceId, metadata }) => {
231
+ const label = this.formatDeviceLabel(deviceId)
187
232
  const reason = metadata?.reason ? ` (${metadata.reason})` : ''
188
- this.log.info(`Device offline: ${deviceId}${reason}`)
233
+ this.log.info(`Device offline: ${label}${reason}`)
189
234
  })
190
235
 
191
- /**
192
- * @param {string} deviceId
193
- * @returns {string}
194
- */
195
- const getDeviceLabel = (deviceId) => {
196
- const deviceName = this.yotoAccount?.getDevice(deviceId)?.device?.name
197
- return deviceName || deviceId
198
- }
199
-
200
236
  /**
201
237
  * @param {{ status?: Record<string, unknown> } | null | undefined} message
202
238
  * @returns {string}
@@ -212,79 +248,85 @@ export class YotoPlatform {
212
248
  }
213
249
 
214
250
  this.yotoAccount.on('statusUpdate', ({ deviceId, source, changedFields }) => {
215
- const label = getDeviceLabel(deviceId)
251
+ const label = this.formatDeviceLabel(deviceId)
216
252
  const fields = Array.from(changedFields).join(', ')
217
253
  this.log.debug(`Status update [${label} ${source}]: ${fields}`)
218
254
  })
219
255
 
220
256
  this.yotoAccount.on('configUpdate', ({ deviceId, changedFields }) => {
221
- const label = getDeviceLabel(deviceId)
257
+ const label = this.formatDeviceLabel(deviceId)
222
258
  const fields = Array.from(changedFields).join(', ')
223
259
  this.log.debug(`Config update [${label}]: ${fields}`)
224
260
  })
225
261
 
226
262
  this.yotoAccount.on('playbackUpdate', ({ deviceId, changedFields }) => {
227
- const label = getDeviceLabel(deviceId)
263
+ const label = this.formatDeviceLabel(deviceId)
228
264
  const fields = Array.from(changedFields).join(', ')
229
265
  this.log.debug(`Playback update [${label}]: ${fields}`)
230
266
  })
231
267
 
232
268
  this.yotoAccount.on('mqttConnect', ({ deviceId }) => {
233
- const label = getDeviceLabel(deviceId)
269
+ const label = this.formatDeviceLabel(deviceId)
234
270
  this.log.debug(`MQTT connected: ${label}`)
235
271
  })
236
272
 
237
273
  this.yotoAccount.on('mqttDisconnect', ({ deviceId, metadata }) => {
238
- const label = getDeviceLabel(deviceId)
274
+ const label = this.formatDeviceLabel(deviceId)
239
275
  const reasonCode = metadata?.packet?.reasonCode
240
276
  const reason = typeof reasonCode === 'number' ? ` (code ${reasonCode})` : ''
241
277
  this.log.warn(`MQTT disconnected: ${label}${reason}`)
242
278
  })
243
279
 
244
280
  this.yotoAccount.on('mqttClose', ({ deviceId, metadata }) => {
245
- const label = getDeviceLabel(deviceId)
281
+ const label = this.formatDeviceLabel(deviceId)
246
282
  const reason = metadata?.reason ? ` (${metadata.reason})` : ''
247
283
  this.log.debug(`MQTT closed: ${label}${reason}`)
248
284
  })
249
285
 
250
286
  this.yotoAccount.on('mqttReconnect', ({ deviceId }) => {
251
- const label = getDeviceLabel(deviceId)
287
+ const label = this.formatDeviceLabel(deviceId)
252
288
  this.log.debug(`MQTT reconnecting: ${label}`)
253
289
  })
254
290
 
255
291
  this.yotoAccount.on('mqttOffline', ({ deviceId }) => {
256
- const label = getDeviceLabel(deviceId)
292
+ const label = this.formatDeviceLabel(deviceId)
257
293
  this.log.debug(`MQTT offline: ${label}`)
258
294
  })
259
295
 
260
296
  this.yotoAccount.on('mqttEnd', ({ deviceId }) => {
261
- const label = getDeviceLabel(deviceId)
297
+ const label = this.formatDeviceLabel(deviceId)
262
298
  this.log.debug(`MQTT ended: ${label}`)
263
299
  })
264
300
 
265
301
  this.yotoAccount.on('mqttStatus', ({ deviceId, topic }) => {
266
- const label = getDeviceLabel(deviceId)
302
+ const label = this.formatDeviceLabel(deviceId)
267
303
  this.log.debug(`MQTT status [${label}]: ${topic}`)
268
304
  })
269
305
 
270
306
  this.yotoAccount.on('mqttEvents', ({ deviceId, topic }) => {
271
- const label = getDeviceLabel(deviceId)
307
+ const label = this.formatDeviceLabel(deviceId)
272
308
  this.log.debug(`MQTT events [${label}]: ${topic}`)
273
309
  })
274
310
 
275
311
  this.yotoAccount.on('mqttStatusLegacy', ({ deviceId, topic, message }) => {
276
- const label = getDeviceLabel(deviceId)
312
+ const label = this.formatDeviceLabel(deviceId)
277
313
  const fields = formatLegacyStatusFields(message)
278
314
  this.log.debug(`MQTT legacy status [${label}]: ${topic}${fields}`)
279
315
  })
280
316
 
281
- this.yotoAccount.on('mqttResponse', ({ deviceId, topic }) => {
282
- const label = getDeviceLabel(deviceId)
283
- this.log.debug(`MQTT response [${label}]: ${topic}`)
317
+ this.yotoAccount.on('mqttResponse', ({ deviceId, topic, message }) => {
318
+ const label = this.formatDeviceLabel(deviceId)
319
+ let payload = ''
320
+ try {
321
+ payload = message ? ` ${JSON.stringify(message)}` : ''
322
+ } catch {
323
+ payload = ' [unserializable message]'
324
+ }
325
+ this.log.debug(`MQTT response [${label}]: ${topic}${payload}`)
284
326
  })
285
327
 
286
328
  this.yotoAccount.on('mqttUnknown', ({ deviceId, topic }) => {
287
- const label = getDeviceLabel(deviceId)
329
+ const label = this.formatDeviceLabel(deviceId)
288
330
  this.log.debug(`MQTT unknown [${label}]: ${topic}`)
289
331
  })
290
332
 
@@ -295,11 +337,59 @@ export class YotoPlatform {
295
337
 
296
338
  // Remove stale accessories after all devices are registered
297
339
  this.removeStaleAccessories()
340
+
341
+ await this.registerCardControlAccessories()
298
342
  } catch (error) {
299
343
  this.log.error('Failed to start account:', error instanceof Error ? error.message : String(error))
300
344
  }
301
345
  }
302
346
 
347
+ /**
348
+ * @param {string} deviceId
349
+ * @returns {string}
350
+ */
351
+ formatDeviceLabel (deviceId) {
352
+ const deviceName = this.yotoAccount?.getDevice(deviceId)?.device?.name
353
+ if (deviceName && deviceName !== deviceId) {
354
+ return `${deviceName} (${deviceId})`
355
+ }
356
+ return deviceId
357
+ }
358
+
359
+ /**
360
+ * @param {string} deviceId
361
+ * @returns {string}
362
+ */
363
+ getSpeakerAccessoryUuid (deviceId) {
364
+ return this.api.hap.uuid.generate(`${deviceId}:speaker`)
365
+ }
366
+
367
+ /**
368
+ * @param {YotoDevice} device
369
+ * @returns {string}
370
+ */
371
+ getSpeakerAccessoryName (device) {
372
+ const rawName = `${device.name} Speaker`
373
+ return sanitizeName(rawName) || `${device.deviceId} Speaker`
374
+ }
375
+
376
+ /**
377
+ * @param {CardControlConfig} control
378
+ * @returns {string}
379
+ */
380
+ getCardControlAccessoryUuid (control) {
381
+ return this.api.hap.uuid.generate(`card-control:${control.id}`)
382
+ }
383
+
384
+ /**
385
+ * @param {CardControlConfig} control
386
+ * @returns {string}
387
+ */
388
+ getCardControlAccessoryName (control) {
389
+ const rawName = `${control.label} (All Yotos)`
390
+ return sanitizeName(rawName) || `${control.cardId} (All Yotos)`
391
+ }
392
+
303
393
  /**
304
394
  * Register a device as a platform accessory
305
395
  * @param {YotoDevice} device - Device to register
@@ -338,6 +428,7 @@ export class YotoPlatform {
338
428
  // Update context with fresh device data
339
429
  existingAccessory.context = {
340
430
  ...existingAccessory.context,
431
+ type: 'device',
341
432
  device,
342
433
  }
343
434
 
@@ -362,6 +453,10 @@ export class YotoPlatform {
362
453
  // Initialize accessory (setup services and event listeners)
363
454
  await handler.setup()
364
455
 
456
+ if (this.playbackAccessoryConfig.mode === 'external') {
457
+ await this.registerSpeakerAccessory(device, deviceModel)
458
+ }
459
+
365
460
  return { success: true }
366
461
  } else {
367
462
  // Create new accessory
@@ -382,6 +477,7 @@ export class YotoPlatform {
382
477
 
383
478
  // Set accessory context
384
479
  accessory.context = {
480
+ type: 'device',
385
481
  device,
386
482
  }
387
483
 
@@ -402,6 +498,10 @@ export class YotoPlatform {
402
498
  this.log.debug(`Registering new accessory: ${device.name}`)
403
499
  this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
404
500
 
501
+ if (this.playbackAccessoryConfig.mode === 'external') {
502
+ await this.registerSpeakerAccessory(device, deviceModel)
503
+ }
504
+
405
505
  // Add to our tracking map (cast to typed version)
406
506
  this.accessories.set(uuid, accessory)
407
507
 
@@ -409,6 +509,169 @@ export class YotoPlatform {
409
509
  }
410
510
  }
411
511
 
512
+ /**
513
+ * Register a device as an external SmartSpeaker accessory
514
+ * @param {YotoDevice} device - Device to register
515
+ * @param {YotoDeviceModel} deviceModel - Device model instance
516
+ * @returns {Promise<{ success: boolean }>} Object indicating if registration succeeded
517
+ */
518
+ async registerSpeakerAccessory (device, deviceModel) {
519
+ const uuid = this.getSpeakerAccessoryUuid(device.deviceId)
520
+ const speakerName = this.getSpeakerAccessoryName(device)
521
+ if (this.speakerAccessories.has(uuid)) {
522
+ this.log.debug('SmartSpeaker accessory already published:', speakerName)
523
+ return { success: true }
524
+ }
525
+
526
+ this.log.info('Adding new SmartSpeaker accessory:', speakerName)
527
+
528
+ /** @type {PlatformAccessory<YotoAccessoryContext>} */
529
+ // eslint-disable-next-line new-cap
530
+ const accessory = new this.api.platformAccessory(
531
+ speakerName,
532
+ uuid,
533
+ this.api.hap.Categories.SPEAKER
534
+ )
535
+
536
+ const infoService = accessory.getService(this.api.hap.Service.AccessoryInformation)
537
+ if (infoService) {
538
+ infoService
539
+ .setCharacteristic(this.api.hap.Characteristic.Name, speakerName)
540
+ .setCharacteristic(this.api.hap.Characteristic.ConfiguredName, speakerName)
541
+ }
542
+
543
+ accessory.context = {
544
+ device,
545
+ }
546
+
547
+ const handler = new YotoSpeakerAccessory({
548
+ platform: this,
549
+ accessory,
550
+ deviceModel,
551
+ })
552
+
553
+ this.speakerAccessoryHandlers.set(uuid, handler)
554
+
555
+ await handler.setup()
556
+
557
+ this.log.info(`Publishing external SmartSpeaker accessory: ${speakerName}`)
558
+ this.api.publishExternalAccessories(PLUGIN_NAME, [accessory])
559
+
560
+ this.speakerAccessories.set(uuid, accessory)
561
+
562
+ return { success: true }
563
+ }
564
+
565
+ /**
566
+ * Register or update card control accessories that target all devices.
567
+ * @returns {Promise<void>}
568
+ */
569
+ async registerCardControlAccessories () {
570
+ const cardControls = getCardControlConfigs(this.config).filter(control => control.playOnAll)
571
+ const desiredUuids = new Set()
572
+
573
+ for (const control of cardControls) {
574
+ const uuid = this.getCardControlAccessoryUuid(control)
575
+ const accessoryName = this.getCardControlAccessoryName(control)
576
+ desiredUuids.add(uuid)
577
+
578
+ const existingAccessory = this.cardAccessories.get(uuid)
579
+ if (existingAccessory) {
580
+ this.log.debug('Restoring existing card control accessory from cache:', accessoryName)
581
+
582
+ if (existingAccessory.displayName !== accessoryName) {
583
+ existingAccessory.updateDisplayName(accessoryName)
584
+ const infoService = existingAccessory.getService(this.api.hap.Service.AccessoryInformation)
585
+ if (infoService) {
586
+ infoService
587
+ .setCharacteristic(this.api.hap.Characteristic.Name, accessoryName)
588
+ .setCharacteristic(this.api.hap.Characteristic.ConfiguredName, accessoryName)
589
+ }
590
+ }
591
+
592
+ existingAccessory.context = {
593
+ type: 'card-control',
594
+ cardControl: control,
595
+ }
596
+
597
+ this.api.updatePlatformAccessories([existingAccessory])
598
+
599
+ const existingHandler = this.cardAccessoryHandlers.get(uuid)
600
+ if (existingHandler) {
601
+ await existingHandler.stop().catch(error => {
602
+ this.log.error(`Failed to stop card control handler for ${existingAccessory.displayName}:`, error)
603
+ })
604
+ this.cardAccessoryHandlers.delete(uuid)
605
+ }
606
+
607
+ const handler = new YotoCardControlAccessory({
608
+ platform: this,
609
+ accessory: existingAccessory,
610
+ cardControl: control,
611
+ })
612
+
613
+ this.cardAccessoryHandlers.set(uuid, handler)
614
+ await handler.setup()
615
+ continue
616
+ }
617
+
618
+ this.log.debug('Adding new card control accessory:', accessoryName)
619
+
620
+ /** @type {PlatformAccessory<YotoCardAccessoryContext>} */
621
+ // eslint-disable-next-line new-cap
622
+ const accessory = new this.api.platformAccessory(
623
+ accessoryName,
624
+ uuid,
625
+ this.api.hap.Categories.SWITCH
626
+ )
627
+
628
+ const infoService = accessory.getService(this.api.hap.Service.AccessoryInformation)
629
+ if (infoService) {
630
+ infoService
631
+ .setCharacteristic(this.api.hap.Characteristic.Name, accessoryName)
632
+ .setCharacteristic(this.api.hap.Characteristic.ConfiguredName, accessoryName)
633
+ }
634
+
635
+ accessory.context = {
636
+ type: 'card-control',
637
+ cardControl: control,
638
+ }
639
+
640
+ const handler = new YotoCardControlAccessory({
641
+ platform: this,
642
+ accessory,
643
+ cardControl: control,
644
+ })
645
+
646
+ this.cardAccessoryHandlers.set(uuid, handler)
647
+ await handler.setup()
648
+
649
+ this.log.debug(`Registering card control accessory: ${accessoryName}`)
650
+ this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
651
+
652
+ this.cardAccessories.set(uuid, accessory)
653
+ }
654
+
655
+ for (const [uuid, accessory] of this.cardAccessories) {
656
+ if (desiredUuids.has(uuid)) {
657
+ continue
658
+ }
659
+
660
+ this.log.debug('Removing card control accessory from cache:', accessory.displayName)
661
+
662
+ const handler = this.cardAccessoryHandlers.get(uuid)
663
+ if (handler) {
664
+ await handler.stop().catch(error => {
665
+ this.log.error(`Failed to stop card control handler for ${accessory.displayName}:`, error)
666
+ })
667
+ this.cardAccessoryHandlers.delete(uuid)
668
+ }
669
+
670
+ this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
671
+ this.cardAccessories.delete(uuid)
672
+ }
673
+ }
674
+
412
675
  /**
413
676
  * Remove accessories that are no longer present in the account
414
677
  */
@@ -441,6 +704,23 @@ export class YotoPlatform {
441
704
  this.accessories.delete(uuid)
442
705
  }
443
706
  }
707
+
708
+ for (const [uuid, accessory] of this.speakerAccessories) {
709
+ const deviceId = accessory.context.device?.deviceId
710
+ if (!deviceId || !currentDeviceIds.includes(deviceId)) {
711
+ this.log.debug('Removing external SmartSpeaker accessory from runtime:', accessory.displayName)
712
+
713
+ const handler = this.speakerAccessoryHandlers.get(uuid)
714
+ if (handler) {
715
+ handler.stop().catch(error => {
716
+ this.log.error(`Failed to stop SmartSpeaker handler for ${accessory.displayName}:`, error)
717
+ })
718
+ this.speakerAccessoryHandlers.delete(uuid)
719
+ }
720
+
721
+ this.speakerAccessories.delete(uuid)
722
+ }
723
+ }
444
724
  }
445
725
 
446
726
  /**
@@ -458,10 +738,28 @@ export class YotoPlatform {
458
738
  })
459
739
  )
460
740
  }
741
+ for (const [uuid, handler] of this.speakerAccessoryHandlers) {
742
+ stopPromises.push(
743
+ handler.stop().catch(error => {
744
+ this.log.error(`Failed to stop SmartSpeaker handler for ${uuid}:`, error)
745
+ })
746
+ )
747
+ }
748
+ for (const [uuid, handler] of this.cardAccessoryHandlers) {
749
+ stopPromises.push(
750
+ handler.stop().catch(error => {
751
+ this.log.error(`Failed to stop card control handler for ${uuid}:`, error)
752
+ })
753
+ )
754
+ }
461
755
 
462
756
  // Wait for all handlers to cleanup
463
757
  await Promise.all(stopPromises)
464
758
  this.accessoryHandlers.clear()
759
+ this.speakerAccessoryHandlers.clear()
760
+ this.cardAccessoryHandlers.clear()
761
+ this.speakerAccessories.clear()
762
+ this.cardAccessories.clear()
465
763
 
466
764
  // Stop the YotoAccount (disconnects all device models and MQTT)
467
765
  if (this.yotoAccount) {
@@ -0,0 +1,63 @@
1
+ /** @import { PlatformConfig } from 'homebridge' */
2
+
3
+ /**
4
+ * @typedef {'bridged' | 'external' | 'none'} PlaybackAccessoryMode
5
+ */
6
+
7
+ /**
8
+ * @typedef {Object} PlaybackAccessoryConfig
9
+ * @property {PlaybackAccessoryMode} mode
10
+ * @property {boolean} playbackEnabled
11
+ * @property {boolean} volumeEnabled
12
+ * @property {boolean} isLegacy
13
+ */
14
+
15
+ const PLAYBACK_ACCESSORY_MODES = new Set(['bridged', 'external', 'none'])
16
+
17
+ /**
18
+ * @param {unknown} value
19
+ * @param {boolean} fallback
20
+ * @returns {boolean}
21
+ */
22
+ function getBooleanSetting (value, fallback) {
23
+ return typeof value === 'boolean' ? value : fallback
24
+ }
25
+
26
+ /**
27
+ * @param {unknown} value
28
+ * @param {PlaybackAccessoryMode} fallback
29
+ * @returns {PlaybackAccessoryMode}
30
+ */
31
+ function parsePlaybackAccessoryMode (value, fallback) {
32
+ if (typeof value === 'string' && PLAYBACK_ACCESSORY_MODES.has(value)) {
33
+ return /** @type {PlaybackAccessoryMode} */ (value)
34
+ }
35
+ return fallback
36
+ }
37
+
38
+ /**
39
+ * @param {PlatformConfig} config
40
+ * @returns {PlaybackAccessoryConfig}
41
+ */
42
+ export function getPlaybackAccessoryConfig (config) {
43
+ const services = config && typeof config === 'object' ? config['services'] : undefined
44
+ const serviceConfig = typeof services === 'object' && services !== null
45
+ ? /** @type {Record<string, unknown>} */ (services)
46
+ : {}
47
+
48
+ const hasPlaybackAccessory = Object.prototype.hasOwnProperty.call(serviceConfig, 'playbackAccessory')
49
+ const legacyPlayback = getBooleanSetting(serviceConfig['playback'], true)
50
+ const legacyVolume = getBooleanSetting(serviceConfig['volume'], true)
51
+
52
+ const legacyMode = legacyPlayback || legacyVolume ? 'bridged' : 'none'
53
+ const mode = hasPlaybackAccessory
54
+ ? parsePlaybackAccessoryMode(serviceConfig['playbackAccessory'], 'bridged')
55
+ : legacyMode
56
+
57
+ return {
58
+ mode,
59
+ playbackEnabled: hasPlaybackAccessory ? mode === 'bridged' : legacyPlayback,
60
+ volumeEnabled: hasPlaybackAccessory ? mode === 'bridged' : legacyVolume,
61
+ isLegacy: !hasPlaybackAccessory,
62
+ }
63
+ }