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/README.md +50 -0
- package/config.schema.json +63 -17
- package/lib/accessory.js +128 -43
- package/lib/card-control-accessory.js +200 -0
- package/lib/card-controls.js +80 -0
- package/lib/platform.js +322 -31
- package/lib/service-config.js +63 -0
- package/lib/speaker-accessory.js +490 -0
- package/package.json +2 -2
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
|
-
|
|
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.
|
|
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.
|
|
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: ${
|
|
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: ${
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
283
|
-
|
|
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 =
|
|
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
|
+
}
|