homebridge-yoto 0.0.39 → 0.0.40

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
 
@@ -110,7 +130,8 @@ export class YotoPlatform {
110
130
  // Listen to account-level events
111
131
  this.yotoAccount.on('error', ({ error, context }) => {
112
132
  if (context.deviceId) {
113
- log.error(`Device error [${context.deviceId} ${context.operation} ${context.source}]:`, error.message)
133
+ const label = this.formatDeviceLabel(context.deviceId)
134
+ log.error(`Device error [${label} ${context.operation} ${context.source}]:`, error.message)
114
135
  } else {
115
136
  log.error('Account error:', error.message)
116
137
  }
@@ -141,9 +162,22 @@ export class YotoPlatform {
141
162
  * @param {PlatformAccessory} accessory - Cached accessory
142
163
  */
143
164
  configureAccessory (accessory) {
144
- const { log, accessories } = this
165
+ const { log, accessories, cardAccessories } = this
145
166
  log.debug('Loading accessory from cache:', accessory.displayName)
146
167
 
168
+ const context = accessory.context
169
+ const record = context && typeof context === 'object'
170
+ ? /** @type {Record<string, unknown>} */ (context)
171
+ : null
172
+ const accessoryType = record && typeof record['type'] === 'string'
173
+ ? record['type']
174
+ : undefined
175
+
176
+ if (accessoryType === 'card-control' || record?.['cardControl']) {
177
+ cardAccessories.set(accessory.UUID, /** @type {PlatformAccessory<YotoCardAccessoryContext>} */ (accessory))
178
+ return
179
+ }
180
+
147
181
  // Add to our tracking map (cast to our typed version)
148
182
  accessories.set(accessory.UUID, /** @type {PlatformAccessory<YotoAccessoryContext>} */ (accessory))
149
183
  }
@@ -164,7 +198,8 @@ export class YotoPlatform {
164
198
  this.yotoAccount.on('deviceAdded', async ({ deviceId }) => {
165
199
  const deviceModel = this.yotoAccount?.getDevice(deviceId)
166
200
  if (!deviceModel) {
167
- this.log.warn(`Device added but no model found for ${deviceId}`)
201
+ const label = this.formatDeviceLabel(deviceId)
202
+ this.log.warn(`Device added but no model found for ${label}`)
168
203
  return
169
204
  }
170
205
 
@@ -174,29 +209,23 @@ export class YotoPlatform {
174
209
  })
175
210
 
176
211
  this.yotoAccount.on('deviceRemoved', ({ deviceId }) => {
177
- this.log.debug(`Device removed: ${deviceId}`)
212
+ const label = this.formatDeviceLabel(deviceId)
213
+ this.log.debug(`Device removed: ${label}`)
178
214
  this.removeStaleAccessories()
179
215
  })
180
216
 
181
217
  this.yotoAccount.on('online', ({ deviceId, metadata }) => {
218
+ const label = this.formatDeviceLabel(deviceId)
182
219
  const reason = metadata?.reason ? ` (${metadata.reason})` : ''
183
- this.log.info(`Device online: ${deviceId}${reason}`)
220
+ this.log.info(`Device online: ${label}${reason}`)
184
221
  })
185
222
 
186
223
  this.yotoAccount.on('offline', ({ deviceId, metadata }) => {
224
+ const label = this.formatDeviceLabel(deviceId)
187
225
  const reason = metadata?.reason ? ` (${metadata.reason})` : ''
188
- this.log.info(`Device offline: ${deviceId}${reason}`)
226
+ this.log.info(`Device offline: ${label}${reason}`)
189
227
  })
190
228
 
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
229
  /**
201
230
  * @param {{ status?: Record<string, unknown> } | null | undefined} message
202
231
  * @returns {string}
@@ -212,79 +241,85 @@ export class YotoPlatform {
212
241
  }
213
242
 
214
243
  this.yotoAccount.on('statusUpdate', ({ deviceId, source, changedFields }) => {
215
- const label = getDeviceLabel(deviceId)
244
+ const label = this.formatDeviceLabel(deviceId)
216
245
  const fields = Array.from(changedFields).join(', ')
217
246
  this.log.debug(`Status update [${label} ${source}]: ${fields}`)
218
247
  })
219
248
 
220
249
  this.yotoAccount.on('configUpdate', ({ deviceId, changedFields }) => {
221
- const label = getDeviceLabel(deviceId)
250
+ const label = this.formatDeviceLabel(deviceId)
222
251
  const fields = Array.from(changedFields).join(', ')
223
252
  this.log.debug(`Config update [${label}]: ${fields}`)
224
253
  })
225
254
 
226
255
  this.yotoAccount.on('playbackUpdate', ({ deviceId, changedFields }) => {
227
- const label = getDeviceLabel(deviceId)
256
+ const label = this.formatDeviceLabel(deviceId)
228
257
  const fields = Array.from(changedFields).join(', ')
229
258
  this.log.debug(`Playback update [${label}]: ${fields}`)
230
259
  })
231
260
 
232
261
  this.yotoAccount.on('mqttConnect', ({ deviceId }) => {
233
- const label = getDeviceLabel(deviceId)
262
+ const label = this.formatDeviceLabel(deviceId)
234
263
  this.log.debug(`MQTT connected: ${label}`)
235
264
  })
236
265
 
237
266
  this.yotoAccount.on('mqttDisconnect', ({ deviceId, metadata }) => {
238
- const label = getDeviceLabel(deviceId)
267
+ const label = this.formatDeviceLabel(deviceId)
239
268
  const reasonCode = metadata?.packet?.reasonCode
240
269
  const reason = typeof reasonCode === 'number' ? ` (code ${reasonCode})` : ''
241
270
  this.log.warn(`MQTT disconnected: ${label}${reason}`)
242
271
  })
243
272
 
244
273
  this.yotoAccount.on('mqttClose', ({ deviceId, metadata }) => {
245
- const label = getDeviceLabel(deviceId)
274
+ const label = this.formatDeviceLabel(deviceId)
246
275
  const reason = metadata?.reason ? ` (${metadata.reason})` : ''
247
276
  this.log.debug(`MQTT closed: ${label}${reason}`)
248
277
  })
249
278
 
250
279
  this.yotoAccount.on('mqttReconnect', ({ deviceId }) => {
251
- const label = getDeviceLabel(deviceId)
280
+ const label = this.formatDeviceLabel(deviceId)
252
281
  this.log.debug(`MQTT reconnecting: ${label}`)
253
282
  })
254
283
 
255
284
  this.yotoAccount.on('mqttOffline', ({ deviceId }) => {
256
- const label = getDeviceLabel(deviceId)
285
+ const label = this.formatDeviceLabel(deviceId)
257
286
  this.log.debug(`MQTT offline: ${label}`)
258
287
  })
259
288
 
260
289
  this.yotoAccount.on('mqttEnd', ({ deviceId }) => {
261
- const label = getDeviceLabel(deviceId)
290
+ const label = this.formatDeviceLabel(deviceId)
262
291
  this.log.debug(`MQTT ended: ${label}`)
263
292
  })
264
293
 
265
294
  this.yotoAccount.on('mqttStatus', ({ deviceId, topic }) => {
266
- const label = getDeviceLabel(deviceId)
295
+ const label = this.formatDeviceLabel(deviceId)
267
296
  this.log.debug(`MQTT status [${label}]: ${topic}`)
268
297
  })
269
298
 
270
299
  this.yotoAccount.on('mqttEvents', ({ deviceId, topic }) => {
271
- const label = getDeviceLabel(deviceId)
300
+ const label = this.formatDeviceLabel(deviceId)
272
301
  this.log.debug(`MQTT events [${label}]: ${topic}`)
273
302
  })
274
303
 
275
304
  this.yotoAccount.on('mqttStatusLegacy', ({ deviceId, topic, message }) => {
276
- const label = getDeviceLabel(deviceId)
305
+ const label = this.formatDeviceLabel(deviceId)
277
306
  const fields = formatLegacyStatusFields(message)
278
307
  this.log.debug(`MQTT legacy status [${label}]: ${topic}${fields}`)
279
308
  })
280
309
 
281
- this.yotoAccount.on('mqttResponse', ({ deviceId, topic }) => {
282
- const label = getDeviceLabel(deviceId)
283
- this.log.debug(`MQTT response [${label}]: ${topic}`)
310
+ this.yotoAccount.on('mqttResponse', ({ deviceId, topic, message }) => {
311
+ const label = this.formatDeviceLabel(deviceId)
312
+ let payload = ''
313
+ try {
314
+ payload = message ? ` ${JSON.stringify(message)}` : ''
315
+ } catch {
316
+ payload = ' [unserializable message]'
317
+ }
318
+ this.log.debug(`MQTT response [${label}]: ${topic}${payload}`)
284
319
  })
285
320
 
286
321
  this.yotoAccount.on('mqttUnknown', ({ deviceId, topic }) => {
287
- const label = getDeviceLabel(deviceId)
322
+ const label = this.formatDeviceLabel(deviceId)
288
323
  this.log.debug(`MQTT unknown [${label}]: ${topic}`)
289
324
  })
290
325
 
@@ -295,11 +330,59 @@ export class YotoPlatform {
295
330
 
296
331
  // Remove stale accessories after all devices are registered
297
332
  this.removeStaleAccessories()
333
+
334
+ await this.registerCardControlAccessories()
298
335
  } catch (error) {
299
336
  this.log.error('Failed to start account:', error instanceof Error ? error.message : String(error))
300
337
  }
301
338
  }
302
339
 
340
+ /**
341
+ * @param {string} deviceId
342
+ * @returns {string}
343
+ */
344
+ formatDeviceLabel (deviceId) {
345
+ const deviceName = this.yotoAccount?.getDevice(deviceId)?.device?.name
346
+ if (deviceName && deviceName !== deviceId) {
347
+ return `${deviceName} (${deviceId})`
348
+ }
349
+ return deviceId
350
+ }
351
+
352
+ /**
353
+ * @param {string} deviceId
354
+ * @returns {string}
355
+ */
356
+ getSpeakerAccessoryUuid (deviceId) {
357
+ return this.api.hap.uuid.generate(`${deviceId}:speaker`)
358
+ }
359
+
360
+ /**
361
+ * @param {YotoDevice} device
362
+ * @returns {string}
363
+ */
364
+ getSpeakerAccessoryName (device) {
365
+ const rawName = `${device.name} Speaker`
366
+ return sanitizeName(rawName) || `${device.deviceId} Speaker`
367
+ }
368
+
369
+ /**
370
+ * @param {CardControlConfig} control
371
+ * @returns {string}
372
+ */
373
+ getCardControlAccessoryUuid (control) {
374
+ return this.api.hap.uuid.generate(`card-control:${control.id}`)
375
+ }
376
+
377
+ /**
378
+ * @param {CardControlConfig} control
379
+ * @returns {string}
380
+ */
381
+ getCardControlAccessoryName (control) {
382
+ const rawName = `${control.label} (All Yotos)`
383
+ return sanitizeName(rawName) || `${control.cardId} (All Yotos)`
384
+ }
385
+
303
386
  /**
304
387
  * Register a device as a platform accessory
305
388
  * @param {YotoDevice} device - Device to register
@@ -338,6 +421,7 @@ export class YotoPlatform {
338
421
  // Update context with fresh device data
339
422
  existingAccessory.context = {
340
423
  ...existingAccessory.context,
424
+ type: 'device',
341
425
  device,
342
426
  }
343
427
 
@@ -362,6 +446,10 @@ export class YotoPlatform {
362
446
  // Initialize accessory (setup services and event listeners)
363
447
  await handler.setup()
364
448
 
449
+ if (this.playbackAccessoryConfig.mode === 'external') {
450
+ await this.registerSpeakerAccessory(device, deviceModel)
451
+ }
452
+
365
453
  return { success: true }
366
454
  } else {
367
455
  // Create new accessory
@@ -382,6 +470,7 @@ export class YotoPlatform {
382
470
 
383
471
  // Set accessory context
384
472
  accessory.context = {
473
+ type: 'device',
385
474
  device,
386
475
  }
387
476
 
@@ -402,6 +491,10 @@ export class YotoPlatform {
402
491
  this.log.debug(`Registering new accessory: ${device.name}`)
403
492
  this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
404
493
 
494
+ if (this.playbackAccessoryConfig.mode === 'external') {
495
+ await this.registerSpeakerAccessory(device, deviceModel)
496
+ }
497
+
405
498
  // Add to our tracking map (cast to typed version)
406
499
  this.accessories.set(uuid, accessory)
407
500
 
@@ -409,6 +502,169 @@ export class YotoPlatform {
409
502
  }
410
503
  }
411
504
 
505
+ /**
506
+ * Register a device as an external SmartSpeaker accessory
507
+ * @param {YotoDevice} device - Device to register
508
+ * @param {YotoDeviceModel} deviceModel - Device model instance
509
+ * @returns {Promise<{ success: boolean }>} Object indicating if registration succeeded
510
+ */
511
+ async registerSpeakerAccessory (device, deviceModel) {
512
+ const uuid = this.getSpeakerAccessoryUuid(device.deviceId)
513
+ const speakerName = this.getSpeakerAccessoryName(device)
514
+ if (this.speakerAccessories.has(uuid)) {
515
+ this.log.debug('SmartSpeaker accessory already published:', speakerName)
516
+ return { success: true }
517
+ }
518
+
519
+ this.log.info('Adding new SmartSpeaker accessory:', speakerName)
520
+
521
+ /** @type {PlatformAccessory<YotoAccessoryContext>} */
522
+ // eslint-disable-next-line new-cap
523
+ const accessory = new this.api.platformAccessory(
524
+ speakerName,
525
+ uuid,
526
+ this.api.hap.Categories.SPEAKER
527
+ )
528
+
529
+ const infoService = accessory.getService(this.api.hap.Service.AccessoryInformation)
530
+ if (infoService) {
531
+ infoService
532
+ .setCharacteristic(this.api.hap.Characteristic.Name, speakerName)
533
+ .setCharacteristic(this.api.hap.Characteristic.ConfiguredName, speakerName)
534
+ }
535
+
536
+ accessory.context = {
537
+ device,
538
+ }
539
+
540
+ const handler = new YotoSpeakerAccessory({
541
+ platform: this,
542
+ accessory,
543
+ deviceModel,
544
+ })
545
+
546
+ this.speakerAccessoryHandlers.set(uuid, handler)
547
+
548
+ await handler.setup()
549
+
550
+ this.log.info(`Publishing external SmartSpeaker accessory: ${speakerName}`)
551
+ this.api.publishExternalAccessories(PLUGIN_NAME, [accessory])
552
+
553
+ this.speakerAccessories.set(uuid, accessory)
554
+
555
+ return { success: true }
556
+ }
557
+
558
+ /**
559
+ * Register or update card control accessories that target all devices.
560
+ * @returns {Promise<void>}
561
+ */
562
+ async registerCardControlAccessories () {
563
+ const cardControls = getCardControlConfigs(this.config).filter(control => control.playOnAll)
564
+ const desiredUuids = new Set()
565
+
566
+ for (const control of cardControls) {
567
+ const uuid = this.getCardControlAccessoryUuid(control)
568
+ const accessoryName = this.getCardControlAccessoryName(control)
569
+ desiredUuids.add(uuid)
570
+
571
+ const existingAccessory = this.cardAccessories.get(uuid)
572
+ if (existingAccessory) {
573
+ this.log.debug('Restoring existing card control accessory from cache:', accessoryName)
574
+
575
+ if (existingAccessory.displayName !== accessoryName) {
576
+ existingAccessory.updateDisplayName(accessoryName)
577
+ const infoService = existingAccessory.getService(this.api.hap.Service.AccessoryInformation)
578
+ if (infoService) {
579
+ infoService
580
+ .setCharacteristic(this.api.hap.Characteristic.Name, accessoryName)
581
+ .setCharacteristic(this.api.hap.Characteristic.ConfiguredName, accessoryName)
582
+ }
583
+ }
584
+
585
+ existingAccessory.context = {
586
+ type: 'card-control',
587
+ cardControl: control,
588
+ }
589
+
590
+ this.api.updatePlatformAccessories([existingAccessory])
591
+
592
+ const existingHandler = this.cardAccessoryHandlers.get(uuid)
593
+ if (existingHandler) {
594
+ await existingHandler.stop().catch(error => {
595
+ this.log.error(`Failed to stop card control handler for ${existingAccessory.displayName}:`, error)
596
+ })
597
+ this.cardAccessoryHandlers.delete(uuid)
598
+ }
599
+
600
+ const handler = new YotoCardControlAccessory({
601
+ platform: this,
602
+ accessory: existingAccessory,
603
+ cardControl: control,
604
+ })
605
+
606
+ this.cardAccessoryHandlers.set(uuid, handler)
607
+ await handler.setup()
608
+ continue
609
+ }
610
+
611
+ this.log.debug('Adding new card control accessory:', accessoryName)
612
+
613
+ /** @type {PlatformAccessory<YotoCardAccessoryContext>} */
614
+ // eslint-disable-next-line new-cap
615
+ const accessory = new this.api.platformAccessory(
616
+ accessoryName,
617
+ uuid,
618
+ this.api.hap.Categories.SWITCH
619
+ )
620
+
621
+ const infoService = accessory.getService(this.api.hap.Service.AccessoryInformation)
622
+ if (infoService) {
623
+ infoService
624
+ .setCharacteristic(this.api.hap.Characteristic.Name, accessoryName)
625
+ .setCharacteristic(this.api.hap.Characteristic.ConfiguredName, accessoryName)
626
+ }
627
+
628
+ accessory.context = {
629
+ type: 'card-control',
630
+ cardControl: control,
631
+ }
632
+
633
+ const handler = new YotoCardControlAccessory({
634
+ platform: this,
635
+ accessory,
636
+ cardControl: control,
637
+ })
638
+
639
+ this.cardAccessoryHandlers.set(uuid, handler)
640
+ await handler.setup()
641
+
642
+ this.log.debug(`Registering card control accessory: ${accessoryName}`)
643
+ this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
644
+
645
+ this.cardAccessories.set(uuid, accessory)
646
+ }
647
+
648
+ for (const [uuid, accessory] of this.cardAccessories) {
649
+ if (desiredUuids.has(uuid)) {
650
+ continue
651
+ }
652
+
653
+ this.log.debug('Removing card control accessory from cache:', accessory.displayName)
654
+
655
+ const handler = this.cardAccessoryHandlers.get(uuid)
656
+ if (handler) {
657
+ await handler.stop().catch(error => {
658
+ this.log.error(`Failed to stop card control handler for ${accessory.displayName}:`, error)
659
+ })
660
+ this.cardAccessoryHandlers.delete(uuid)
661
+ }
662
+
663
+ this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
664
+ this.cardAccessories.delete(uuid)
665
+ }
666
+ }
667
+
412
668
  /**
413
669
  * Remove accessories that are no longer present in the account
414
670
  */
@@ -441,6 +697,23 @@ export class YotoPlatform {
441
697
  this.accessories.delete(uuid)
442
698
  }
443
699
  }
700
+
701
+ for (const [uuid, accessory] of this.speakerAccessories) {
702
+ const deviceId = accessory.context.device?.deviceId
703
+ if (!deviceId || !currentDeviceIds.includes(deviceId)) {
704
+ this.log.debug('Removing external SmartSpeaker accessory from runtime:', accessory.displayName)
705
+
706
+ const handler = this.speakerAccessoryHandlers.get(uuid)
707
+ if (handler) {
708
+ handler.stop().catch(error => {
709
+ this.log.error(`Failed to stop SmartSpeaker handler for ${accessory.displayName}:`, error)
710
+ })
711
+ this.speakerAccessoryHandlers.delete(uuid)
712
+ }
713
+
714
+ this.speakerAccessories.delete(uuid)
715
+ }
716
+ }
444
717
  }
445
718
 
446
719
  /**
@@ -458,10 +731,28 @@ export class YotoPlatform {
458
731
  })
459
732
  )
460
733
  }
734
+ for (const [uuid, handler] of this.speakerAccessoryHandlers) {
735
+ stopPromises.push(
736
+ handler.stop().catch(error => {
737
+ this.log.error(`Failed to stop SmartSpeaker handler for ${uuid}:`, error)
738
+ })
739
+ )
740
+ }
741
+ for (const [uuid, handler] of this.cardAccessoryHandlers) {
742
+ stopPromises.push(
743
+ handler.stop().catch(error => {
744
+ this.log.error(`Failed to stop card control handler for ${uuid}:`, error)
745
+ })
746
+ )
747
+ }
461
748
 
462
749
  // Wait for all handlers to cleanup
463
750
  await Promise.all(stopPromises)
464
751
  this.accessoryHandlers.clear()
752
+ this.speakerAccessoryHandlers.clear()
753
+ this.cardAccessoryHandlers.clear()
754
+ this.speakerAccessories.clear()
755
+ this.cardAccessories.clear()
465
756
 
466
757
  // Stop the YotoAccount (disconnects all device models and MQTT)
467
758
  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
+ }