@mdjhnson/homebridge-yoto 0.1.0

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.
@@ -0,0 +1,1019 @@
1
+ /**
2
+ * @fileoverview Main platform implementation for Yoto Homebridge plugin
3
+ */
4
+
5
+ /** @import { API, DynamicPlatformPlugin, Logger, PlatformAccessory, PlatformConfig, Service, Characteristic } from 'homebridge' */
6
+ /** @import { YotoDevice } from 'yoto-nodejs-client/lib/api-endpoints/devices.js' */
7
+ /** @import { YotoDeviceModel } from 'yoto-nodejs-client' */
8
+ /** @import { PlaybackAccessoryConfig } from './service-config.js' */
9
+ /** @import { CardControlConfig } from './card-controls.js' */
10
+
11
+ /**
12
+ * Context stored in PlatformAccessory for Yoto devices
13
+ * @typedef {Object} YotoAccessoryContext
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
23
+ */
24
+
25
+ import { readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
26
+ import { YotoAccount } from 'yoto-nodejs-client'
27
+ import { randomUUID } from 'node:crypto'
28
+ import {
29
+ PLATFORM_NAME,
30
+ PLUGIN_NAME,
31
+ DEFAULT_CLIENT_ID,
32
+ } from './settings.js'
33
+ import { YotoPlayerAccessory } from './accessory.js'
34
+ import { YotoSpeakerAccessory } from './speaker-accessory.js'
35
+ import { YotoTelevisionAccessory } from './television-accessory.js'
36
+ import { YotoCardControlAccessory } from './card-control-accessory.js'
37
+ import { sanitizeName } from './utils/sanitize-name.js'
38
+ import { getPlaybackAccessoryConfig } from './service-config.js'
39
+ import { getCardControlConfigs } from './card-controls.js'
40
+ import { formatError } from './utils/error-format.js'
41
+ import { applyTokenUpdate } from './utils/token-config.js'
42
+ import { tolerateMissingStatusScope } from './utils/status-scope-fallback.js'
43
+
44
+ /**
45
+ * Yoto Platform implementation
46
+ * This class is the main constructor for your plugin, this is where you should
47
+ * parse the user config and discover/register accessories with Homebridge.
48
+ * @implements {DynamicPlatformPlugin}
49
+ */
50
+ export class YotoPlatform {
51
+ /** @type {Logger} */ log
52
+ /** @type {PlatformConfig} */ config
53
+ /** @type {API} */ api
54
+ /** @type {typeof Service} */ Service
55
+ /** @type {typeof Characteristic} */ Characteristic
56
+ /** @type {PlaybackAccessoryConfig} */ playbackAccessoryConfig
57
+ /** @type {Map<string, PlatformAccessory<YotoAccessoryContext>>} */ accessories = new Map()
58
+ /** @type {Map<string, PlatformAccessory<YotoAccessoryContext>>} */ speakerAccessories = new Map()
59
+ /** @type {Map<string, PlatformAccessory<YotoAccessoryContext>>} */ televisionAccessories = new Map()
60
+ /** @type {Map<string, PlatformAccessory<YotoCardAccessoryContext>>} */ cardAccessories = new Map()
61
+ /** @type {Map<string, YotoPlayerAccessory>} */ accessoryHandlers = new Map()
62
+ /** @type {Map<string, YotoSpeakerAccessory>} */ speakerAccessoryHandlers = new Map()
63
+ /** @type {Map<string, YotoTelevisionAccessory>} */ televisionAccessoryHandlers = new Map()
64
+ /** @type {Map<string, YotoCardControlAccessory>} */ cardAccessoryHandlers = new Map()
65
+ /** @type {YotoAccount | null} */ yotoAccount = null
66
+ /** @type {string} */ sessionId = randomUUID()
67
+ /** @type {Map<string, Promise<string | null>>} */ cardTitleCache = new Map()
68
+
69
+ /**
70
+ * @param {Logger} log - Homebridge logger
71
+ * @param {PlatformConfig} config - Platform configuration
72
+ * @param {API} api - Homebridge API
73
+ */
74
+ constructor (log, config, api) {
75
+ this.log = log
76
+ this.config = config
77
+ this.api = api
78
+ this.Service = api.hap.Service
79
+ this.Characteristic = api.hap.Characteristic
80
+ this.playbackAccessoryConfig = getPlaybackAccessoryConfig(config)
81
+
82
+ log.debug('Finished initializing platform:', config.name)
83
+
84
+ // Extract auth tokens once
85
+ const clientId = config['clientId'] || DEFAULT_CLIENT_ID
86
+ const refreshToken = config['refreshToken']
87
+ const accessToken = config['accessToken']
88
+
89
+ // Debug: Log what we found (redacted)
90
+ log.debug('Config check - Has refreshToken:', !!refreshToken)
91
+ log.debug('Config check - Has accessToken:', !!accessToken)
92
+ log.debug('Config check - ClientId:', clientId ? 'present' : 'missing')
93
+
94
+ // Check if we have authentication tokens
95
+ if (!refreshToken || !accessToken) {
96
+ log.warn('No authentication tokens found. Please configure the plugin through the Homebridge UI.')
97
+ return
98
+ }
99
+
100
+ log.debug('Authentication tokens found, initializing Yoto account...')
101
+
102
+ const { sessionId } = this
103
+ const updateHomebridgeConfig = this.updateHomebridgeConfig.bind(this)
104
+
105
+ // Initialize YotoAccount with client and device options
106
+ this.yotoAccount = new YotoAccount({
107
+ clientOptions: {
108
+ clientId,
109
+ refreshToken,
110
+ accessToken,
111
+ onTokenRefresh: async ({ updatedAccessToken, updatedRefreshToken, updatedExpiresAt, prevAccessToken, prevRefreshToken }) => {
112
+ // Client reports expiry in seconds; config.json stores milliseconds (matches the UI)
113
+ const tokenExpiresAt = updatedExpiresAt * 1000
114
+ log.debug('Access token refreshed, expires at:', new Date(tokenExpiresAt).toISOString())
115
+
116
+ // Keep the in-memory config in sync so later reads see current tokens
117
+ config['accessToken'] = updatedAccessToken
118
+ config['refreshToken'] = updatedRefreshToken
119
+ config['tokenExpiresAt'] = tokenExpiresAt
120
+
121
+ await updateHomebridgeConfig((configContents) => applyTokenUpdate(configContents, {
122
+ platform: PLATFORM_NAME,
123
+ accessToken: updatedAccessToken,
124
+ refreshToken: updatedRefreshToken,
125
+ tokenExpiresAt,
126
+ prevAccessToken,
127
+ prevRefreshToken,
128
+ }))
129
+ },
130
+ onRefreshStart: () => {
131
+ log.debug('Refreshing Yoto access token...')
132
+ },
133
+ onRefreshError: (error) => {
134
+ log.warn('Yoto token refresh failed, will retry:', error.message)
135
+ log.debug('Token refresh error details:', formatError(error))
136
+ },
137
+ onInvalid: (error) => {
138
+ log.error(
139
+ 'Yoto login has expired or been revoked. Open the plugin settings in the Homebridge UI and sign in again.',
140
+ error.message
141
+ )
142
+ },
143
+ },
144
+ deviceOptions: {
145
+ httpPollIntervalMs: Math.max(10000, Number(config['httpPollIntervalMs']) || 60000),
146
+ yotoDeviceMqttOptions: {
147
+ sessionId
148
+ }
149
+ }
150
+ })
151
+
152
+ // The status endpoint needs a scope the Yoto dashboard doesn't offer yet
153
+ tolerateMissingStatusScope(this.yotoAccount.client, (message) => log.debug(message))
154
+
155
+ // Listen to account-level events
156
+ this.yotoAccount.on('error', ({ error, context }) => {
157
+ const details = formatError(error)
158
+ if (context.deviceId) {
159
+ const label = this.formatDeviceLabel(context.deviceId)
160
+ log.error(`Device error [${label} ${context.operation} ${context.source}]:`, details)
161
+ log.debug('Device error context:', context)
162
+ } else {
163
+ log.error('Account error:', details)
164
+ log.debug('Account error context:', context)
165
+ }
166
+ })
167
+
168
+ // When this event is fired it means Homebridge has restored all cached accessories from disk.
169
+ // Dynamic Platform plugins should only register new accessories after this event was fired,
170
+ // in order to ensure they weren't added to homebridge already. This event can also be used
171
+ // to start discovery of new accessories.
172
+ api.on('didFinishLaunching', async () => {
173
+ log.debug('Executed didFinishLaunching callback')
174
+ // Start the YotoAccount which will discover and start all devices
175
+ await this.startAccount()
176
+ })
177
+
178
+ // When homebridge shuts down, cleanup all handlers and MQTT connections
179
+ api.on('shutdown', () => {
180
+ log.debug('Homebridge shutting down, cleaning up accessories...')
181
+ this.shutdown()
182
+ })
183
+ }
184
+
185
+ /**
186
+ * This function is invoked when homebridge restores cached accessories from disk at startup.
187
+ * It should be used to set up event handlers for characteristics and update respective values.
188
+ * In practice, it never is. It simply collects previously created devices into an accessories map
189
+ * that is then used to setup devices after didFinishLaunching fires.
190
+ * @param {PlatformAccessory} accessory - Cached accessory
191
+ */
192
+ configureAccessory (accessory) {
193
+ const { log, accessories, cardAccessories } = this
194
+ log.debug('Loading accessory from cache:', accessory.displayName, accessory.UUID)
195
+
196
+ const context = accessory.context
197
+ const record = context && typeof context === 'object'
198
+ ? /** @type {Record<string, unknown>} */ (context)
199
+ : null
200
+ const accessoryType = record && typeof record['type'] === 'string'
201
+ ? record['type']
202
+ : undefined
203
+ log.debug('Cached accessory context type:', accessory.displayName, accessoryType ?? 'device')
204
+
205
+ if (accessoryType === 'card-control' || record?.['cardControl']) {
206
+ cardAccessories.set(accessory.UUID, /** @type {PlatformAccessory<YotoCardAccessoryContext>} */ (accessory))
207
+ return
208
+ }
209
+
210
+ // Add to our tracking map (cast to our typed version)
211
+ accessories.set(accessory.UUID, /** @type {PlatformAccessory<YotoAccessoryContext>} */ (accessory))
212
+ }
213
+
214
+ /**
215
+ * Start YotoAccount - discovers devices and creates device models
216
+ */
217
+ async startAccount () {
218
+ if (!this.yotoAccount) {
219
+ this.log.error('Cannot start account - YotoAccount not initialized')
220
+ return
221
+ }
222
+
223
+ try {
224
+ this.log.debug('Starting Yoto account...')
225
+ this.log.debug(
226
+ 'Playback controls config:',
227
+ `playback=${this.playbackAccessoryConfig.playbackEnabled}`,
228
+ `volume=${this.playbackAccessoryConfig.volumeEnabled}`,
229
+ `smartSpeaker=${this.playbackAccessoryConfig.smartSpeakerEnabled}`,
230
+ `tvPlayback=${this.playbackAccessoryConfig.televisionEnabled}`
231
+ )
232
+
233
+ // Listen for devices being added
234
+ this.yotoAccount.on('deviceAdded', async ({ deviceId }) => {
235
+ // Homebridge may shut down while the account is still starting
236
+ if (!this.yotoAccount) return
237
+ const deviceModel = this.yotoAccount.getDevice(deviceId)
238
+ if (!deviceModel) {
239
+ const label = this.formatDeviceLabel(deviceId)
240
+ this.log.warn(`Device added but no model found for ${label}`)
241
+ return
242
+ }
243
+
244
+ const device = deviceModel.device
245
+ this.log.info(`Device discovered: ${device.name} (${deviceId})`)
246
+ this.log.debug('Registering device from account discovery:', device.name, deviceId)
247
+ await this.registerDevice(device, deviceModel)
248
+ })
249
+
250
+ this.yotoAccount.on('deviceRemoved', ({ deviceId }) => {
251
+ const label = this.formatDeviceLabel(deviceId)
252
+ this.log.debug(`Device removed: ${label}`)
253
+ this.removeStaleAccessories()
254
+ })
255
+
256
+ this.yotoAccount.on('online', ({ deviceId, metadata }) => {
257
+ const label = this.formatDeviceLabel(deviceId)
258
+ const reason = metadata?.reason ? ` (${metadata.reason})` : ''
259
+ this.log.info(`Device online: ${label}${reason}`)
260
+ })
261
+
262
+ this.yotoAccount.on('offline', ({ deviceId, metadata }) => {
263
+ const label = this.formatDeviceLabel(deviceId)
264
+ const reason = metadata?.reason ? ` (${metadata.reason})` : ''
265
+ this.log.info(`Device offline: ${label}${reason}`)
266
+ })
267
+
268
+ /**
269
+ * @param {{ status?: Record<string, unknown> } | null | undefined} message
270
+ * @returns {string}
271
+ */
272
+ const formatLegacyStatusFields = (message) => {
273
+ const status = message?.status
274
+ if (!status || typeof status !== 'object') return ''
275
+ const fields = Object.keys(status)
276
+ if (!fields.length) return ''
277
+ const preview = fields.slice(0, 8).join(', ')
278
+ const suffix = fields.length > 8 ? `, +${fields.length - 8} more` : ''
279
+ return ` fields: ${preview}${suffix}`
280
+ }
281
+
282
+ this.yotoAccount.on('statusUpdate', ({ deviceId, source, changedFields }) => {
283
+ const label = this.formatDeviceLabel(deviceId)
284
+ const fields = Array.from(changedFields).join(', ')
285
+ this.log.debug(`Status update [${label} ${source}]: ${fields}`)
286
+ })
287
+
288
+ this.yotoAccount.on('configUpdate', ({ deviceId, changedFields }) => {
289
+ const label = this.formatDeviceLabel(deviceId)
290
+ const fields = Array.from(changedFields).join(', ')
291
+ this.log.debug(`Config update [${label}]: ${fields}`)
292
+ })
293
+
294
+ this.yotoAccount.on('playbackUpdate', ({ deviceId, changedFields }) => {
295
+ const label = this.formatDeviceLabel(deviceId)
296
+ const fields = Array.from(changedFields).join(', ')
297
+ this.log.debug(`Playback update [${label}]: ${fields}`)
298
+ })
299
+
300
+ this.yotoAccount.on('mqttConnect', ({ deviceId }) => {
301
+ const label = this.formatDeviceLabel(deviceId)
302
+ this.log.debug(`MQTT connected: ${label}`)
303
+ })
304
+
305
+ this.yotoAccount.on('mqttDisconnect', ({ deviceId, metadata }) => {
306
+ const label = this.formatDeviceLabel(deviceId)
307
+ const reasonCode = metadata?.packet?.reasonCode
308
+ const reason = typeof reasonCode === 'number' ? ` (code ${reasonCode})` : ''
309
+ this.log.warn(`MQTT disconnected: ${label}${reason}`)
310
+ })
311
+
312
+ this.yotoAccount.on('mqttClose', ({ deviceId, metadata }) => {
313
+ const label = this.formatDeviceLabel(deviceId)
314
+ const reason = metadata?.reason ? ` (${metadata.reason})` : ''
315
+ this.log.debug(`MQTT closed: ${label}${reason}`)
316
+ })
317
+
318
+ this.yotoAccount.on('mqttReconnect', ({ deviceId }) => {
319
+ const label = this.formatDeviceLabel(deviceId)
320
+ this.log.debug(`MQTT reconnecting: ${label}`)
321
+ })
322
+
323
+ this.yotoAccount.on('mqttOffline', ({ deviceId }) => {
324
+ const label = this.formatDeviceLabel(deviceId)
325
+ this.log.debug(`MQTT offline: ${label}`)
326
+ })
327
+
328
+ this.yotoAccount.on('mqttEnd', ({ deviceId }) => {
329
+ const label = this.formatDeviceLabel(deviceId)
330
+ this.log.debug(`MQTT ended: ${label}`)
331
+ })
332
+
333
+ this.yotoAccount.on('mqttStatus', ({ deviceId, topic }) => {
334
+ const label = this.formatDeviceLabel(deviceId)
335
+ this.log.debug(`MQTT status [${label}]: ${topic}`)
336
+ })
337
+
338
+ this.yotoAccount.on('mqttEvents', ({ deviceId, topic }) => {
339
+ const label = this.formatDeviceLabel(deviceId)
340
+ this.log.debug(`MQTT events [${label}]: ${topic}`)
341
+ })
342
+
343
+ this.yotoAccount.on('mqttStatusLegacy', ({ deviceId, topic, message }) => {
344
+ const label = this.formatDeviceLabel(deviceId)
345
+ const fields = formatLegacyStatusFields(message)
346
+ this.log.debug(`MQTT legacy status [${label}]: ${topic}${fields}`)
347
+ })
348
+
349
+ this.yotoAccount.on('mqttResponse', ({ deviceId, topic, message }) => {
350
+ const label = this.formatDeviceLabel(deviceId)
351
+ let payload = ''
352
+ try {
353
+ payload = message ? ` ${JSON.stringify(message)}` : ''
354
+ } catch {
355
+ payload = ' [unserializable message]'
356
+ }
357
+ this.log.debug(`MQTT response [${label}]: ${topic}${payload}`)
358
+ })
359
+
360
+ this.yotoAccount.on('mqttUnknown', ({ deviceId, topic }) => {
361
+ const label = this.formatDeviceLabel(deviceId)
362
+ this.log.debug(`MQTT unknown [${label}]: ${topic}`)
363
+ })
364
+
365
+ // Start the account (discovers devices, creates device models, starts MQTT)
366
+ const account = this.yotoAccount
367
+ await account.start()
368
+
369
+ // Homebridge shut down while the account was starting; shutdown() cleans up
370
+ if (this.yotoAccount !== account) return
371
+
372
+ this.log.info(`✓ Yoto account started with ${account.devices.size} device(s)`)
373
+ this.log.debug(
374
+ 'Account devices:',
375
+ Array.from(account.devices.keys()).join(', ') || 'none'
376
+ )
377
+
378
+ // Remove stale accessories after all devices are registered
379
+ this.removeStaleAccessories()
380
+
381
+ this.log.debug('Registering card control accessories (playOnAll).')
382
+ await this.registerCardControlAccessories()
383
+ } catch (error) {
384
+ this.log.error('Failed to start account:', error instanceof Error ? error.message : String(error))
385
+ }
386
+ }
387
+
388
+ /**
389
+ * @param {string} deviceId
390
+ * @returns {string}
391
+ */
392
+ formatDeviceLabel (deviceId) {
393
+ const deviceName = this.yotoAccount?.getDevice(deviceId)?.device?.name
394
+ if (deviceName && deviceName !== deviceId) {
395
+ return `${deviceName} (${deviceId})`
396
+ }
397
+ return deviceId
398
+ }
399
+
400
+ /**
401
+ * Look up a card's title from the Yoto content API (cached per runtime).
402
+ * Resolves to null when the title can't be fetched.
403
+ * @param {string} cardId
404
+ * @returns {Promise<string | null>}
405
+ */
406
+ getCardTitle (cardId) {
407
+ const cached = this.cardTitleCache.get(cardId)
408
+ if (cached) return cached
409
+
410
+ const client = this.yotoAccount?.client
411
+ if (!client) return Promise.resolve(null)
412
+
413
+ const lookup = client.getContent({ cardId })
414
+ .then(response => response.card?.title?.trim() || null)
415
+ .catch(error => {
416
+ const statusCode = error && typeof error === 'object' && 'statusCode' in error ? error.statusCode : undefined
417
+ this.log.debug(`Could not look up title for card ${cardId}${statusCode ? ` (HTTP ${statusCode})` : ''}`)
418
+ // Forbidden cards (e.g. Yoto system cards) stay unresolved; retry anything else later
419
+ if (statusCode !== 403) this.cardTitleCache.delete(cardId)
420
+ return null
421
+ })
422
+ this.cardTitleCache.set(cardId, lookup)
423
+ return lookup
424
+ }
425
+
426
+ /**
427
+ * @param {string} deviceId
428
+ * @returns {string}
429
+ */
430
+ getSpeakerAccessoryUuid (deviceId) {
431
+ return this.api.hap.uuid.generate(`${deviceId}:speaker`)
432
+ }
433
+
434
+ /**
435
+ * @param {YotoDevice} device
436
+ * @returns {string}
437
+ */
438
+ getSpeakerAccessoryName (device) {
439
+ const rawName = `${device.name} Speaker`
440
+ return sanitizeName(rawName) || `${device.deviceId} Speaker`
441
+ }
442
+
443
+ /**
444
+ * @param {string} deviceId
445
+ * @returns {string}
446
+ */
447
+ getTelevisionAccessoryUuid (deviceId) {
448
+ return this.api.hap.uuid.generate(`${deviceId}:playback`)
449
+ }
450
+
451
+ /**
452
+ * @param {YotoDevice} device
453
+ * @returns {string}
454
+ */
455
+ getTelevisionAccessoryName (device) {
456
+ const rawName = `${device.name} Playback`
457
+ return sanitizeName(rawName) || `${device.deviceId} Playback`
458
+ }
459
+
460
+ /**
461
+ * @param {CardControlConfig} control
462
+ * @returns {string}
463
+ */
464
+ getCardControlAccessoryUuid (control) {
465
+ return this.api.hap.uuid.generate(`card-control:${control.id}`)
466
+ }
467
+
468
+ /**
469
+ * @param {CardControlConfig} control
470
+ * @returns {string}
471
+ */
472
+ getCardControlAccessoryName (control) {
473
+ const rawName = `${control.label} (All Yotos)`
474
+ return sanitizeName(rawName) || `${control.cardId} (All Yotos)`
475
+ }
476
+
477
+ /**
478
+ * Register a device as a platform accessory
479
+ * @param {YotoDevice} device - Device to register
480
+ * @param {YotoDeviceModel} deviceModel - Device model instance
481
+ * @returns {Promise<{ success: boolean }>} Object indicating if registration succeeded
482
+ */
483
+ async registerDevice (device, deviceModel) {
484
+ // Generate UUID for this device
485
+ const uuid = this.api.hap.uuid.generate(device.deviceId)
486
+ const sanitizedDeviceName = sanitizeName(device.name)
487
+ const accessoryCategory = this.api.hap.Categories.SPEAKER
488
+ this.log.debug(
489
+ 'Register device:',
490
+ `${device.name} (${device.deviceId})`,
491
+ `uuid=${uuid}`,
492
+ `category=${accessoryCategory}`
493
+ )
494
+
495
+ // Stop any handler left over from a previous registration of this device
496
+ await this.stopHandler(this.accessoryHandlers, uuid, 'device')
497
+
498
+ // Check if accessory already exists
499
+ const existingAccessory = this.accessories.get(uuid)
500
+
501
+ if (existingAccessory) {
502
+ // Accessory exists - update it
503
+ this.log.debug('Restoring existing accessory from cache:', device.name, existingAccessory.UUID)
504
+
505
+ // Update display name if it has changed
506
+ if (existingAccessory.displayName !== sanitizedDeviceName) {
507
+ this.log.debug('Updating accessory display name:', existingAccessory.displayName, '->', sanitizedDeviceName)
508
+ existingAccessory.updateDisplayName(sanitizedDeviceName)
509
+ }
510
+
511
+ // Update context with fresh device data
512
+ existingAccessory.context = {
513
+ ...existingAccessory.context,
514
+ type: 'device',
515
+ device,
516
+ }
517
+
518
+ // Ensure category matches our current service model
519
+ if (existingAccessory.category !== accessoryCategory) {
520
+ existingAccessory.category = accessoryCategory
521
+ }
522
+
523
+ // Update accessory information
524
+ this.api.updatePlatformAccessories([existingAccessory])
525
+ this.log.debug('Updated accessory cache entry:', existingAccessory.displayName, existingAccessory.UUID)
526
+
527
+ // Create handler for this accessory with device model
528
+ const handler = new YotoPlayerAccessory({
529
+ platform: this,
530
+ accessory: existingAccessory,
531
+ deviceModel,
532
+ })
533
+
534
+ // Track handler
535
+ this.accessoryHandlers.set(uuid, handler)
536
+ this.log.debug('Created accessory handler:', existingAccessory.displayName, uuid)
537
+
538
+ // Initialize accessory (setup services and event listeners)
539
+ await handler.setup()
540
+ this.log.debug('Accessory setup complete:', existingAccessory.displayName)
541
+
542
+ if (this.playbackAccessoryConfig.smartSpeakerEnabled) {
543
+ await this.registerSpeakerAccessory(device, deviceModel)
544
+ }
545
+
546
+ if (this.playbackAccessoryConfig.televisionEnabled) {
547
+ await this.registerTelevisionAccessory(device, deviceModel)
548
+ }
549
+
550
+ return { success: true }
551
+ } else {
552
+ // Create new accessory
553
+ this.log.debug('Adding new accessory:', device.name, uuid)
554
+
555
+ // Create platform accessory
556
+ /** @type {PlatformAccessory<YotoAccessoryContext>} */
557
+ // eslint-disable-next-line new-cap
558
+ const accessory = new this.api.platformAccessory(sanitizedDeviceName, uuid, accessoryCategory)
559
+
560
+ // Set accessory context
561
+ accessory.context = {
562
+ type: 'device',
563
+ device,
564
+ }
565
+
566
+ // Create handler for this accessory with device model
567
+ const handler = new YotoPlayerAccessory({
568
+ platform: this,
569
+ accessory,
570
+ deviceModel,
571
+ })
572
+
573
+ // Track handler
574
+ this.accessoryHandlers.set(uuid, handler)
575
+ this.log.debug('Created accessory handler:', device.name, uuid)
576
+
577
+ // Initialize accessory (setup services and event listeners)
578
+ await handler.setup()
579
+ this.log.debug('Accessory setup complete:', device.name)
580
+
581
+ // Register as a platform accessory (bridged).
582
+ this.log.debug(`Registering new accessory: ${device.name}`)
583
+ this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
584
+ this.log.debug('Registered platform accessory:', device.name, uuid)
585
+
586
+ if (this.playbackAccessoryConfig.smartSpeakerEnabled) {
587
+ await this.registerSpeakerAccessory(device, deviceModel)
588
+ }
589
+
590
+ if (this.playbackAccessoryConfig.televisionEnabled) {
591
+ await this.registerTelevisionAccessory(device, deviceModel)
592
+ }
593
+
594
+ // Add to our tracking map (cast to typed version)
595
+ this.accessories.set(uuid, accessory)
596
+ this.log.debug('Tracked new accessory:', device.name, uuid)
597
+
598
+ return { success: true }
599
+ }
600
+ }
601
+
602
+ /**
603
+ * Register a device as an external SmartSpeaker accessory
604
+ * @param {YotoDevice} device - Device to register
605
+ * @param {YotoDeviceModel} deviceModel - Device model instance
606
+ * @returns {Promise<{ success: boolean }>} Object indicating if registration succeeded
607
+ */
608
+ async registerSpeakerAccessory (device, deviceModel) {
609
+ const uuid = this.getSpeakerAccessoryUuid(device.deviceId)
610
+ const speakerName = this.getSpeakerAccessoryName(device)
611
+ const publishedAccessory = this.speakerAccessories.get(uuid)
612
+ if (publishedAccessory) {
613
+ // External accessories can only be published once per runtime, so
614
+ // re-attach a fresh handler for the (possibly new) device model.
615
+ this.log.debug('SmartSpeaker accessory already published, re-attaching handler:', speakerName, uuid)
616
+ await this.stopHandler(this.speakerAccessoryHandlers, uuid, 'SmartSpeaker')
617
+ publishedAccessory.context = { device }
618
+ const handler = new YotoSpeakerAccessory({
619
+ platform: this,
620
+ accessory: publishedAccessory,
621
+ deviceModel,
622
+ })
623
+ this.speakerAccessoryHandlers.set(uuid, handler)
624
+ await handler.setup()
625
+ return { success: true }
626
+ }
627
+
628
+ this.log.info('Adding new SmartSpeaker accessory:', speakerName)
629
+ this.log.debug('Creating SmartSpeaker accessory:', speakerName, uuid)
630
+
631
+ /** @type {PlatformAccessory<YotoAccessoryContext>} */
632
+ // eslint-disable-next-line new-cap
633
+ const accessory = new this.api.platformAccessory(
634
+ speakerName,
635
+ uuid,
636
+ this.api.hap.Categories.SPEAKER
637
+ )
638
+
639
+ accessory.context = {
640
+ device,
641
+ }
642
+
643
+ const handler = new YotoSpeakerAccessory({
644
+ platform: this,
645
+ accessory,
646
+ deviceModel,
647
+ })
648
+
649
+ this.speakerAccessoryHandlers.set(uuid, handler)
650
+ this.log.debug('Created SmartSpeaker handler:', speakerName, uuid)
651
+
652
+ await handler.setup()
653
+ this.log.debug('SmartSpeaker setup complete:', speakerName)
654
+
655
+ this.log.info(`Publishing external SmartSpeaker accessory: ${speakerName}`)
656
+ this.api.publishExternalAccessories(PLUGIN_NAME, [accessory])
657
+ this.log.debug('Published external SmartSpeaker accessory:', speakerName, uuid)
658
+
659
+ this.speakerAccessories.set(uuid, accessory)
660
+
661
+ return { success: true }
662
+ }
663
+
664
+ /**
665
+ * Register a device as an external Television accessory
666
+ * @param {YotoDevice} device - Device to register
667
+ * @param {YotoDeviceModel} deviceModel - Device model instance
668
+ * @returns {Promise<{ success: boolean }>} Object indicating if registration succeeded
669
+ */
670
+ async registerTelevisionAccessory (device, deviceModel) {
671
+ const uuid = this.getTelevisionAccessoryUuid(device.deviceId)
672
+ const playbackName = this.getTelevisionAccessoryName(device)
673
+ const publishedAccessory = this.televisionAccessories.get(uuid)
674
+ if (publishedAccessory) {
675
+ // External accessories can only be published once per runtime, so
676
+ // re-attach a fresh handler for the (possibly new) device model.
677
+ this.log.debug('Television playback accessory already published, re-attaching handler:', playbackName, uuid)
678
+ await this.stopHandler(this.televisionAccessoryHandlers, uuid, 'Television playback')
679
+ publishedAccessory.context = { device }
680
+ const handler = new YotoTelevisionAccessory({
681
+ platform: this,
682
+ accessory: publishedAccessory,
683
+ deviceModel,
684
+ })
685
+ this.televisionAccessoryHandlers.set(uuid, handler)
686
+ await handler.setup()
687
+ return { success: true }
688
+ }
689
+
690
+ this.log.info('Adding new Television playback accessory:', playbackName)
691
+ this.log.debug('Creating Television playback accessory:', playbackName, uuid)
692
+
693
+ /** @type {PlatformAccessory<YotoAccessoryContext>} */
694
+ // eslint-disable-next-line new-cap
695
+ const accessory = new this.api.platformAccessory(
696
+ playbackName,
697
+ uuid,
698
+ this.api.hap.Categories.TELEVISION
699
+ )
700
+
701
+ accessory.context = {
702
+ device,
703
+ }
704
+
705
+ const handler = new YotoTelevisionAccessory({
706
+ platform: this,
707
+ accessory,
708
+ deviceModel,
709
+ })
710
+
711
+ this.televisionAccessoryHandlers.set(uuid, handler)
712
+ this.log.debug('Created Television playback handler:', playbackName, uuid)
713
+
714
+ await handler.setup()
715
+ this.log.debug('Television playback setup complete:', playbackName)
716
+
717
+ this.log.info(`Publishing external Television playback accessory: ${playbackName}`)
718
+ this.api.publishExternalAccessories(PLUGIN_NAME, [accessory])
719
+ this.log.debug('Published external Television playback accessory:', playbackName, uuid)
720
+
721
+ this.televisionAccessories.set(uuid, accessory)
722
+
723
+ return { success: true }
724
+ }
725
+
726
+ /**
727
+ * Register or update card control accessories that target all devices.
728
+ * @returns {Promise<void>}
729
+ */
730
+ async registerCardControlAccessories () {
731
+ const cardControls = getCardControlConfigs(this.config).filter(control => control.playOnAll)
732
+ const desiredUuids = new Set()
733
+ this.log.debug('Card control configs (playOnAll):', cardControls.length)
734
+
735
+ for (const control of cardControls) {
736
+ const uuid = this.getCardControlAccessoryUuid(control)
737
+ const accessoryName = this.getCardControlAccessoryName(control)
738
+ desiredUuids.add(uuid)
739
+ this.log.debug('Ensuring card control accessory:', accessoryName, uuid)
740
+
741
+ const existingAccessory = this.cardAccessories.get(uuid)
742
+ if (existingAccessory) {
743
+ this.log.debug('Restoring existing card control accessory from cache:', accessoryName, uuid)
744
+
745
+ if (existingAccessory.displayName !== accessoryName) {
746
+ this.log.debug('Updating card control display name:', existingAccessory.displayName, '->', accessoryName)
747
+ existingAccessory.updateDisplayName(accessoryName)
748
+ }
749
+
750
+ existingAccessory.context = {
751
+ type: 'card-control',
752
+ cardControl: control,
753
+ }
754
+
755
+ this.api.updatePlatformAccessories([existingAccessory])
756
+ this.log.debug('Updated card control cache entry:', existingAccessory.displayName, uuid)
757
+
758
+ const existingHandler = this.cardAccessoryHandlers.get(uuid)
759
+ if (existingHandler) {
760
+ await existingHandler.stop().catch(error => {
761
+ this.log.error(`Failed to stop card control handler for ${existingAccessory.displayName}:`, error)
762
+ })
763
+ this.cardAccessoryHandlers.delete(uuid)
764
+ }
765
+
766
+ const handler = new YotoCardControlAccessory({
767
+ platform: this,
768
+ accessory: existingAccessory,
769
+ cardControl: control,
770
+ })
771
+
772
+ this.cardAccessoryHandlers.set(uuid, handler)
773
+ await handler.setup()
774
+ this.log.debug('Card control setup complete:', accessoryName)
775
+ continue
776
+ }
777
+
778
+ this.log.debug('Adding new card control accessory:', accessoryName, uuid)
779
+
780
+ /** @type {PlatformAccessory<YotoCardAccessoryContext>} */
781
+ // eslint-disable-next-line new-cap
782
+ const accessory = new this.api.platformAccessory(
783
+ accessoryName,
784
+ uuid,
785
+ this.api.hap.Categories.SWITCH
786
+ )
787
+
788
+ accessory.context = {
789
+ type: 'card-control',
790
+ cardControl: control,
791
+ }
792
+
793
+ const handler = new YotoCardControlAccessory({
794
+ platform: this,
795
+ accessory,
796
+ cardControl: control,
797
+ })
798
+
799
+ this.cardAccessoryHandlers.set(uuid, handler)
800
+ await handler.setup()
801
+ this.log.debug('Card control setup complete:', accessoryName)
802
+
803
+ this.log.debug(`Registering card control accessory: ${accessoryName}`)
804
+ this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
805
+ this.log.debug('Registered card control accessory:', accessoryName, uuid)
806
+
807
+ this.cardAccessories.set(uuid, accessory)
808
+ this.log.debug('Tracked card control accessory:', accessoryName, uuid)
809
+ }
810
+
811
+ for (const [uuid, accessory] of this.cardAccessories) {
812
+ if (desiredUuids.has(uuid)) {
813
+ continue
814
+ }
815
+
816
+ this.log.debug('Removing card control accessory from cache:', accessory.displayName, uuid)
817
+
818
+ const handler = this.cardAccessoryHandlers.get(uuid)
819
+ if (handler) {
820
+ await handler.stop().catch(error => {
821
+ this.log.error(`Failed to stop card control handler for ${accessory.displayName}:`, error)
822
+ })
823
+ this.cardAccessoryHandlers.delete(uuid)
824
+ }
825
+
826
+ this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
827
+ this.cardAccessories.delete(uuid)
828
+ this.log.debug('Removed card control accessory:', accessory.displayName, uuid)
829
+ }
830
+ }
831
+
832
+ /**
833
+ * Remove accessories that are no longer present in the account
834
+ */
835
+ removeStaleAccessories () {
836
+ if (!this.yotoAccount) {
837
+ return
838
+ }
839
+
840
+ // Get current device IDs from account
841
+ const currentDeviceIds = this.yotoAccount.getDeviceIds()
842
+ const currentUUIDs = currentDeviceIds.map(id => this.api.hap.uuid.generate(id))
843
+ this.log.debug(
844
+ 'Evaluating stale accessories:',
845
+ `accountDevices=${currentDeviceIds.length}`,
846
+ `cachedAccessories=${this.accessories.size}`,
847
+ `externalSpeakers=${this.speakerAccessories.size}`,
848
+ `externalPlayback=${this.televisionAccessories.size}`
849
+ )
850
+
851
+ for (const [uuid, accessory] of this.accessories) {
852
+ if (!currentUUIDs.includes(uuid)) {
853
+ this.log.debug('Removing existing accessory from cache:', accessory.displayName, uuid)
854
+
855
+ // Stop handler if it exists
856
+ const handler = this.accessoryHandlers.get(uuid)
857
+ if (handler) {
858
+ handler.stop().catch(error => {
859
+ this.log.error(`Failed to stop handler for ${accessory.displayName}:`, error)
860
+ })
861
+ this.accessoryHandlers.delete(uuid)
862
+ }
863
+
864
+ // Unregister from Homebridge
865
+ this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
866
+ this.log.debug('Unregistered accessory from Homebridge:', accessory.displayName, uuid)
867
+
868
+ // Remove from our tracking map
869
+ this.accessories.delete(uuid)
870
+ this.log.debug('Removed accessory from tracking map:', accessory.displayName, uuid)
871
+ }
872
+ }
873
+
874
+ for (const [uuid, accessory] of this.speakerAccessories) {
875
+ const deviceId = accessory.context.device?.deviceId
876
+ if (!deviceId || !currentDeviceIds.includes(deviceId)) {
877
+ const handler = this.speakerAccessoryHandlers.get(uuid)
878
+ if (handler) {
879
+ handler.stop().catch(error => {
880
+ this.log.error(`Failed to stop SmartSpeaker handler for ${accessory.displayName}:`, error)
881
+ })
882
+ this.speakerAccessoryHandlers.delete(uuid)
883
+ // Homebridge has no API to unpublish external accessories. Keep it tracked so
884
+ // it is re-used if the device comes back; it disappears after a restart.
885
+ this.log.warn(`${accessory.displayName} is no longer in your Yoto account. Remove it from the Home app manually.`)
886
+ }
887
+ }
888
+ }
889
+
890
+ for (const [uuid, accessory] of this.televisionAccessories) {
891
+ const deviceId = accessory.context.device?.deviceId
892
+ if (!deviceId || !currentDeviceIds.includes(deviceId)) {
893
+ const handler = this.televisionAccessoryHandlers.get(uuid)
894
+ if (handler) {
895
+ handler.stop().catch(error => {
896
+ this.log.error(`Failed to stop Television playback handler for ${accessory.displayName}:`, error)
897
+ })
898
+ this.televisionAccessoryHandlers.delete(uuid)
899
+ // Homebridge has no API to unpublish external accessories. Keep it tracked so
900
+ // it is re-used if the device comes back; it disappears after a restart.
901
+ this.log.warn(`${accessory.displayName} is no longer in your Yoto account. Remove it from the Home app manually.`)
902
+ }
903
+ }
904
+ }
905
+ }
906
+
907
+ /**
908
+ * Stop and forget a tracked handler, if one exists.
909
+ * @param {Map<string, { stop: () => Promise<void> }>} handlers
910
+ * @param {string} uuid
911
+ * @param {string} label
912
+ * @returns {Promise<void>}
913
+ */
914
+ async stopHandler (handlers, uuid, label) {
915
+ const handler = handlers.get(uuid)
916
+ if (!handler) return
917
+ handlers.delete(uuid)
918
+ await handler.stop().catch(error => {
919
+ this.log.error(`Failed to stop ${label} handler for ${uuid}:`, error)
920
+ })
921
+ }
922
+
923
+ /**
924
+ * Shutdown platform - cleanup all handlers and stop account
925
+ */
926
+ async shutdown () {
927
+ this.log.debug('Shutting down Yoto platform...')
928
+ this.log.debug(
929
+ 'Handlers to stop:',
930
+ `devices=${this.accessoryHandlers.size}`,
931
+ `speakers=${this.speakerAccessoryHandlers.size}`,
932
+ `televisions=${this.televisionAccessoryHandlers.size}`,
933
+ `cardControls=${this.cardAccessoryHandlers.size}`
934
+ )
935
+
936
+ // Stop all accessory handlers
937
+ const stopPromises = []
938
+ for (const [uuid, handler] of this.accessoryHandlers) {
939
+ stopPromises.push(
940
+ handler.stop().catch(error => {
941
+ this.log.error(`Failed to stop handler for ${uuid}:`, error)
942
+ })
943
+ )
944
+ }
945
+ for (const [uuid, handler] of this.speakerAccessoryHandlers) {
946
+ stopPromises.push(
947
+ handler.stop().catch(error => {
948
+ this.log.error(`Failed to stop SmartSpeaker handler for ${uuid}:`, error)
949
+ })
950
+ )
951
+ }
952
+ for (const [uuid, handler] of this.televisionAccessoryHandlers) {
953
+ stopPromises.push(
954
+ handler.stop().catch(error => {
955
+ this.log.error(`Failed to stop Television playback handler for ${uuid}:`, error)
956
+ })
957
+ )
958
+ }
959
+ for (const [uuid, handler] of this.cardAccessoryHandlers) {
960
+ stopPromises.push(
961
+ handler.stop().catch(error => {
962
+ this.log.error(`Failed to stop card control handler for ${uuid}:`, error)
963
+ })
964
+ )
965
+ }
966
+
967
+ // Wait for all handlers to cleanup
968
+ await Promise.all(stopPromises)
969
+ this.accessoryHandlers.clear()
970
+ this.speakerAccessoryHandlers.clear()
971
+ this.televisionAccessoryHandlers.clear()
972
+ this.cardAccessoryHandlers.clear()
973
+ this.speakerAccessories.clear()
974
+ this.televisionAccessories.clear()
975
+ this.cardAccessories.clear()
976
+
977
+ // Stop the YotoAccount (disconnects all device models and MQTT)
978
+ if (this.yotoAccount) {
979
+ await this.yotoAccount.stop()
980
+ this.yotoAccount = null
981
+ }
982
+
983
+ this.log.debug('✓ Yoto platform shutdown complete')
984
+ }
985
+
986
+ /**
987
+ * Update Homebridge config.json file
988
+ * @param {(configContents: string) => string | null} updateFn - Returns updated contents, or null if it could not apply the update
989
+ */
990
+ async updateHomebridgeConfig (updateFn) {
991
+ const configPath = this.api.user.configPath()
992
+
993
+ try {
994
+ const configContents = await readFile(configPath, 'utf8')
995
+ const updatedContents = updateFn(configContents)
996
+ if (updatedContents === null) {
997
+ this.log.warn(`Did not save refreshed tokens: no ${PLATFORM_NAME} platform block in ${configPath} holds the current tokens. If you signed in again or logged out in the plugin settings, restart Homebridge to use the new settings.`)
998
+ return
999
+ }
1000
+ // Write to a temp file and rename so a crash mid-write cannot corrupt config.json.
1001
+ // Keep the original file mode, since config.json holds tokens.
1002
+ const { mode } = await stat(configPath)
1003
+ const tmpPath = `${configPath}.${process.pid}.tmp`
1004
+ await writeFile(tmpPath, updatedContents, { encoding: 'utf8', mode: mode & 0o777 })
1005
+ try {
1006
+ await rename(tmpPath, configPath)
1007
+ } catch (renameError) {
1008
+ // e.g. config.json is a single-file bind mount (EBUSY); write it in place instead
1009
+ await unlink(tmpPath).catch(() => {})
1010
+ this.log.debug('Could not replace config.json atomically, writing in place:', formatError(renameError))
1011
+ await writeFile(configPath, updatedContents, 'utf8')
1012
+ }
1013
+ this.log.debug('Updated config.json with new tokens')
1014
+ } catch (error) {
1015
+ const details = error instanceof Error ? error.message : String(error)
1016
+ this.log.error(`Failed to update config.json at ${configPath}. Refreshed tokens may not persist:`, details)
1017
+ }
1018
+ }
1019
+ }