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/README.md +50 -0
- package/config.schema.json +63 -17
- package/lib/accessory.js +144 -62
- package/lib/card-control-accessory.js +200 -0
- package/lib/card-controls.js +80 -0
- package/lib/platform.js +326 -27
- 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,25 +209,27 @@ 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
|
-
|
|
193
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
275
|
-
|
|
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 =
|
|
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
|
+
}
|