homebridge-yoto 0.0.38 → 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,25 +209,27 @@ 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
- const getDeviceLabel = (deviceId) => {
192
- const deviceName = this.yotoAccount?.getDevice(deviceId)?.device?.name
193
- return deviceName || deviceId
194
- }
195
-
229
+ /**
230
+ * @param {{ status?: Record<string, unknown> } | null | undefined} message
231
+ * @returns {string}
232
+ */
196
233
  const formatLegacyStatusFields = (message) => {
197
234
  const status = message?.status
198
235
  if (!status || typeof status !== 'object') return ''
@@ -204,79 +241,85 @@ export class YotoPlatform {
204
241
  }
205
242
 
206
243
  this.yotoAccount.on('statusUpdate', ({ deviceId, source, changedFields }) => {
207
- const label = getDeviceLabel(deviceId)
244
+ const label = this.formatDeviceLabel(deviceId)
208
245
  const fields = Array.from(changedFields).join(', ')
209
246
  this.log.debug(`Status update [${label} ${source}]: ${fields}`)
210
247
  })
211
248
 
212
249
  this.yotoAccount.on('configUpdate', ({ deviceId, changedFields }) => {
213
- const label = getDeviceLabel(deviceId)
250
+ const label = this.formatDeviceLabel(deviceId)
214
251
  const fields = Array.from(changedFields).join(', ')
215
252
  this.log.debug(`Config update [${label}]: ${fields}`)
216
253
  })
217
254
 
218
255
  this.yotoAccount.on('playbackUpdate', ({ deviceId, changedFields }) => {
219
- const label = getDeviceLabel(deviceId)
256
+ const label = this.formatDeviceLabel(deviceId)
220
257
  const fields = Array.from(changedFields).join(', ')
221
258
  this.log.debug(`Playback update [${label}]: ${fields}`)
222
259
  })
223
260
 
224
261
  this.yotoAccount.on('mqttConnect', ({ deviceId }) => {
225
- const label = getDeviceLabel(deviceId)
262
+ const label = this.formatDeviceLabel(deviceId)
226
263
  this.log.debug(`MQTT connected: ${label}`)
227
264
  })
228
265
 
229
266
  this.yotoAccount.on('mqttDisconnect', ({ deviceId, metadata }) => {
230
- const label = getDeviceLabel(deviceId)
267
+ const label = this.formatDeviceLabel(deviceId)
231
268
  const reasonCode = metadata?.packet?.reasonCode
232
269
  const reason = typeof reasonCode === 'number' ? ` (code ${reasonCode})` : ''
233
270
  this.log.warn(`MQTT disconnected: ${label}${reason}`)
234
271
  })
235
272
 
236
273
  this.yotoAccount.on('mqttClose', ({ deviceId, metadata }) => {
237
- const label = getDeviceLabel(deviceId)
274
+ const label = this.formatDeviceLabel(deviceId)
238
275
  const reason = metadata?.reason ? ` (${metadata.reason})` : ''
239
276
  this.log.debug(`MQTT closed: ${label}${reason}`)
240
277
  })
241
278
 
242
279
  this.yotoAccount.on('mqttReconnect', ({ deviceId }) => {
243
- const label = getDeviceLabel(deviceId)
280
+ const label = this.formatDeviceLabel(deviceId)
244
281
  this.log.debug(`MQTT reconnecting: ${label}`)
245
282
  })
246
283
 
247
284
  this.yotoAccount.on('mqttOffline', ({ deviceId }) => {
248
- const label = getDeviceLabel(deviceId)
285
+ const label = this.formatDeviceLabel(deviceId)
249
286
  this.log.debug(`MQTT offline: ${label}`)
250
287
  })
251
288
 
252
289
  this.yotoAccount.on('mqttEnd', ({ deviceId }) => {
253
- const label = getDeviceLabel(deviceId)
290
+ const label = this.formatDeviceLabel(deviceId)
254
291
  this.log.debug(`MQTT ended: ${label}`)
255
292
  })
256
293
 
257
294
  this.yotoAccount.on('mqttStatus', ({ deviceId, topic }) => {
258
- const label = getDeviceLabel(deviceId)
295
+ const label = this.formatDeviceLabel(deviceId)
259
296
  this.log.debug(`MQTT status [${label}]: ${topic}`)
260
297
  })
261
298
 
262
299
  this.yotoAccount.on('mqttEvents', ({ deviceId, topic }) => {
263
- const label = getDeviceLabel(deviceId)
300
+ const label = this.formatDeviceLabel(deviceId)
264
301
  this.log.debug(`MQTT events [${label}]: ${topic}`)
265
302
  })
266
303
 
267
304
  this.yotoAccount.on('mqttStatusLegacy', ({ deviceId, topic, message }) => {
268
- const label = getDeviceLabel(deviceId)
305
+ const label = this.formatDeviceLabel(deviceId)
269
306
  const fields = formatLegacyStatusFields(message)
270
307
  this.log.debug(`MQTT legacy status [${label}]: ${topic}${fields}`)
271
308
  })
272
309
 
273
- this.yotoAccount.on('mqttResponse', ({ deviceId, topic }) => {
274
- const label = getDeviceLabel(deviceId)
275
- 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}`)
276
319
  })
277
320
 
278
321
  this.yotoAccount.on('mqttUnknown', ({ deviceId, topic }) => {
279
- const label = getDeviceLabel(deviceId)
322
+ const label = this.formatDeviceLabel(deviceId)
280
323
  this.log.debug(`MQTT unknown [${label}]: ${topic}`)
281
324
  })
282
325
 
@@ -287,11 +330,59 @@ export class YotoPlatform {
287
330
 
288
331
  // Remove stale accessories after all devices are registered
289
332
  this.removeStaleAccessories()
333
+
334
+ await this.registerCardControlAccessories()
290
335
  } catch (error) {
291
336
  this.log.error('Failed to start account:', error instanceof Error ? error.message : String(error))
292
337
  }
293
338
  }
294
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
+
295
386
  /**
296
387
  * Register a device as a platform accessory
297
388
  * @param {YotoDevice} device - Device to register
@@ -330,6 +421,7 @@ export class YotoPlatform {
330
421
  // Update context with fresh device data
331
422
  existingAccessory.context = {
332
423
  ...existingAccessory.context,
424
+ type: 'device',
333
425
  device,
334
426
  }
335
427
 
@@ -354,6 +446,10 @@ export class YotoPlatform {
354
446
  // Initialize accessory (setup services and event listeners)
355
447
  await handler.setup()
356
448
 
449
+ if (this.playbackAccessoryConfig.mode === 'external') {
450
+ await this.registerSpeakerAccessory(device, deviceModel)
451
+ }
452
+
357
453
  return { success: true }
358
454
  } else {
359
455
  // Create new accessory
@@ -374,6 +470,7 @@ export class YotoPlatform {
374
470
 
375
471
  // Set accessory context
376
472
  accessory.context = {
473
+ type: 'device',
377
474
  device,
378
475
  }
379
476
 
@@ -394,6 +491,10 @@ export class YotoPlatform {
394
491
  this.log.debug(`Registering new accessory: ${device.name}`)
395
492
  this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
396
493
 
494
+ if (this.playbackAccessoryConfig.mode === 'external') {
495
+ await this.registerSpeakerAccessory(device, deviceModel)
496
+ }
497
+
397
498
  // Add to our tracking map (cast to typed version)
398
499
  this.accessories.set(uuid, accessory)
399
500
 
@@ -401,6 +502,169 @@ export class YotoPlatform {
401
502
  }
402
503
  }
403
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
+
404
668
  /**
405
669
  * Remove accessories that are no longer present in the account
406
670
  */
@@ -433,6 +697,23 @@ export class YotoPlatform {
433
697
  this.accessories.delete(uuid)
434
698
  }
435
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
+ }
436
717
  }
437
718
 
438
719
  /**
@@ -450,10 +731,28 @@ export class YotoPlatform {
450
731
  })
451
732
  )
452
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
+ }
453
748
 
454
749
  // Wait for all handlers to cleanup
455
750
  await Promise.all(stopPromises)
456
751
  this.accessoryHandlers.clear()
752
+ this.speakerAccessoryHandlers.clear()
753
+ this.cardAccessoryHandlers.clear()
754
+ this.speakerAccessories.clear()
755
+ this.cardAccessories.clear()
457
756
 
458
757
  // Stop the YotoAccount (disconnects all device models and MQTT)
459
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
+ }