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