@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,987 @@
1
+ /**
2
+ * @fileoverview Yoto TV playback accessory implementation for external accessories.
3
+ */
4
+
5
+ /** @import { PlatformAccessory, CharacteristicValue, Service, Logger } from 'homebridge' */
6
+ /** @import { YotoPlatform } from './platform.js' */
7
+ /** @import { YotoDeviceModel } from 'yoto-nodejs-client' */
8
+ /** @import { YotoDeviceModelEventMap } from 'yoto-nodejs-client/lib/yoto-device.js' */
9
+ /** @import { YotoDevice } from 'yoto-nodejs-client/lib/api-endpoints/devices.js' */
10
+ /** @import { YotoAccessoryContext } from './platform.js' */
11
+ /** @import { PlayableCard } from './card-controls.js' */
12
+
13
+ /**
14
+ * A TV input and the content it plays (null for the "now playing" input).
15
+ * @typedef {Object} TelevisionInput
16
+ * @property {Service} service
17
+ * @property {PlayableCard | null} card
18
+ */
19
+
20
+ import { setTimeout as delay } from 'node:timers/promises'
21
+ import {
22
+ DEFAULT_MANUFACTURER,
23
+ DEFAULT_MODEL,
24
+ LOG_PREFIX,
25
+ } from './settings.js'
26
+ import { sanitizeName } from './utils/sanitize-name.js'
27
+ import { syncServiceNames } from './sync-service-names.js'
28
+ import { formatError } from './utils/error-format.js'
29
+ import { ListenerGroup } from './utils/listener-group.js'
30
+ import { setDeviceVolume } from './utils/set-device-volume.js'
31
+ import { getTrimmedString } from './utils/get-trimmed-string.js'
32
+ import { getCardControlConfigs } from './card-controls.js'
33
+ import { getCardStartOptions, getShortcutEntries, getShortcutsSignature, toPlayableCard } from './shortcuts.js'
34
+ import { getShortcutsEnabled } from './service-config.js'
35
+ import {
36
+ clampPercent,
37
+ clampSteps,
38
+ percentToSteps,
39
+ stepsFromVolumeValue,
40
+ stepsToPercent,
41
+ } from './utils/volume.js'
42
+
43
+ /** Identifier of the input that represents whatever is currently playing */
44
+ const NOW_PLAYING_INPUT_ID = 1
45
+ const INPUT_SUBTYPE_PREFIXES = ['CardInput:', 'ShortcutInput:']
46
+ /**
47
+ * Picking an input while the TV is off makes Home send Active=1 and then the
48
+ * input. Wait this long before resuming so a picked input can cancel the resume.
49
+ */
50
+ const RESUME_DELAY_MS = 750
51
+
52
+ /**
53
+ * Yoto Television Playback Accessory Handler (external)
54
+ * Manages Television, InputSource, and TelevisionSpeaker services for a single Yoto player.
55
+ */
56
+ export class YotoTelevisionAccessory {
57
+ /** @type {YotoPlatform} */ #platform
58
+ /** @type {PlatformAccessory<YotoAccessoryContext>} */ #accessory
59
+ /** @type {YotoDeviceModel} */ #deviceModel
60
+ /** @type {Logger} */ #log
61
+ /** @type {YotoDevice} */ #device
62
+ /** @type {Service | undefined} */ televisionService
63
+ /** @type {Service | undefined} */ inputSourceService
64
+ /** @type {Service | undefined} */ speakerService
65
+ /** @type {number} */ #lastNonZeroVolume = 50
66
+ /** @type {Set<Service>} */ #currentServices = new Set()
67
+ /** @type {ListenerGroup<YotoDeviceModelEventMap>} */ #listeners
68
+ /** @type {Map<number, TelevisionInput>} */ #inputs = new Map()
69
+ /** @type {string} */ #shortcutsSignature = ''
70
+ /** @type {number} */ #resumeRequest = 0
71
+
72
+ /**
73
+ * @param {Object} params
74
+ * @param {YotoPlatform} params.platform - Platform instance
75
+ * @param {PlatformAccessory<YotoAccessoryContext>} params.accessory - Platform accessory
76
+ * @param {YotoDeviceModel} params.deviceModel - Yoto device model with live state
77
+ */
78
+ constructor ({ platform, accessory, deviceModel }) {
79
+ this.#platform = platform
80
+ this.#accessory = accessory
81
+ this.#deviceModel = deviceModel
82
+ this.#listeners = new ListenerGroup(deviceModel)
83
+ this.#log = platform.log
84
+
85
+ this.#device = accessory.context.device
86
+ this.#currentServices = new Set()
87
+ }
88
+
89
+ /**
90
+ * Setup accessory - create services and setup event listeners
91
+ * @returns {Promise<void>}
92
+ */
93
+ async setup () {
94
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Setting up TV playback for ${this.#device.name}`)
95
+
96
+ this.#currentServices.clear()
97
+
98
+ this.setupAccessoryInformation()
99
+ this.setupTelevisionService()
100
+ this.setupInputSources()
101
+ this.setupTelevisionSpeakerService()
102
+
103
+ // Iterate a copy: removeService splices the live array
104
+ for (const service of [...this.#accessory.services]) {
105
+ if (service.UUID !== this.#platform.Service.AccessoryInformation.UUID &&
106
+ !this.#currentServices.has(service)) {
107
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Removing stale TV service: ${service.displayName || service.UUID}`)
108
+ this.#accessory.removeService(service)
109
+ }
110
+ }
111
+
112
+ this.setupEventListeners()
113
+
114
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `✓ ${this.#device.name} playback ready`)
115
+ }
116
+
117
+ /**
118
+ * Setup AccessoryInformation service
119
+ */
120
+ setupAccessoryInformation () {
121
+ const { Service, Characteristic } = this.#platform
122
+ const service = this.#accessory.getService(Service.AccessoryInformation) ||
123
+ this.#accessory.addService(Service.AccessoryInformation)
124
+ const displayName = sanitizeName(this.#accessory.displayName)
125
+ const nameCharacteristic = service.getCharacteristic(Characteristic.Name)
126
+ const configuredCharacteristic = service.getCharacteristic(Characteristic.ConfiguredName)
127
+ const previousName = nameCharacteristic.value
128
+ const configuredName = configuredCharacteristic.value
129
+
130
+ const hardwareRevision = [
131
+ this.#device.generation,
132
+ this.#device.formFactor,
133
+ ].filter(Boolean).join(' ') || 'Unknown'
134
+
135
+ const model = this.#device.deviceFamily || this.#device.deviceType || DEFAULT_MODEL
136
+
137
+ service
138
+ .setCharacteristic(Characteristic.Name, displayName)
139
+ .setCharacteristic(Characteristic.Manufacturer, DEFAULT_MANUFACTURER)
140
+ .setCharacteristic(Characteristic.Model, model)
141
+ .setCharacteristic(Characteristic.SerialNumber, this.#device.deviceId)
142
+ .setCharacteristic(Characteristic.HardwareRevision, hardwareRevision)
143
+
144
+ if (typeof configuredName !== 'string' || configuredName === previousName) {
145
+ service.setCharacteristic(Characteristic.ConfiguredName, displayName)
146
+ }
147
+
148
+ if (this.#deviceModel.status.firmwareVersion) {
149
+ service.setCharacteristic(
150
+ Characteristic.FirmwareRevision,
151
+ this.#deviceModel.status.firmwareVersion
152
+ )
153
+ }
154
+
155
+ this.#currentServices.add(service)
156
+ }
157
+
158
+ /**
159
+ * Setup Television service (PRIMARY)
160
+ */
161
+ setupTelevisionService () {
162
+ const { Service, Characteristic } = this.#platform
163
+ const serviceName = sanitizeName(this.#accessory.displayName)
164
+
165
+ const service = this.#accessory.getService(Service.Television) ||
166
+ this.#accessory.addService(Service.Television, serviceName)
167
+
168
+ service.setPrimaryService(true)
169
+ service.displayName = serviceName
170
+ // Set both display and configured names explicitly for the TV service and keep them in sync.
171
+ service
172
+ .setCharacteristic(Characteristic.Name, serviceName)
173
+ .setCharacteristic(Characteristic.ConfiguredName, serviceName)
174
+
175
+ service
176
+ .getCharacteristic(Characteristic.ConfiguredName)
177
+ .onSet((value) => {
178
+ const incomingName = getTrimmedString(value)
179
+ const configuredName = sanitizeName(incomingName) || serviceName
180
+ if (service.displayName !== configuredName) {
181
+ service.displayName = configuredName
182
+ }
183
+ service.updateCharacteristic(Characteristic.Name, configuredName)
184
+ if (configuredName !== incomingName) {
185
+ service.updateCharacteristic(Characteristic.ConfiguredName, configuredName)
186
+ }
187
+ })
188
+
189
+ service
190
+ .getCharacteristic(Characteristic.Active)
191
+ .onGet(this.getActive.bind(this))
192
+ .onSet(this.setActive.bind(this))
193
+
194
+ service
195
+ .getCharacteristic(Characteristic.ActiveIdentifier)
196
+ .onGet(this.getActiveIdentifier.bind(this))
197
+ .onSet(this.setActiveIdentifier.bind(this))
198
+
199
+ service
200
+ .getCharacteristic(Characteristic.RemoteKey)
201
+ .onSet(this.setRemoteKey.bind(this))
202
+
203
+ service.setCharacteristic(
204
+ Characteristic.SleepDiscoveryMode,
205
+ Characteristic.SleepDiscoveryMode.ALWAYS_DISCOVERABLE
206
+ )
207
+
208
+ service.addOptionalCharacteristic(Characteristic.CurrentMediaState)
209
+ service.addOptionalCharacteristic(Characteristic.TargetMediaState)
210
+
211
+ service
212
+ .getCharacteristic(Characteristic.CurrentMediaState)
213
+ .onGet(this.getCurrentMediaState.bind(this))
214
+
215
+ service
216
+ .getCharacteristic(Characteristic.TargetMediaState)
217
+ .onGet(this.getTargetMediaState.bind(this))
218
+ .onSet(this.setTargetMediaState.bind(this))
219
+
220
+ service.setCharacteristic(Characteristic.ActiveIdentifier, NOW_PLAYING_INPUT_ID)
221
+
222
+ this.televisionService = service
223
+ this.#currentServices.add(service)
224
+ }
225
+
226
+ /**
227
+ * Setup InputSource services: "now playing", configured card controls, and
228
+ * (when enabled) device shortcuts. Removes inputs that no longer exist.
229
+ */
230
+ setupInputSources () {
231
+ /** @type {Map<number, TelevisionInput>} */
232
+ const inputs = new Map()
233
+ let identifier = NOW_PLAYING_INPUT_ID
234
+
235
+ const nowPlaying = this.configureInputSource('PlaybackInput', identifier, `${this.#device.name} Now Playing`)
236
+ inputs.set(identifier, { service: nowPlaying, card: null })
237
+
238
+ for (const control of getCardControlConfigs(this.#platform.config)) {
239
+ identifier += 1
240
+ const service = this.configureInputSource(`CardInput:${control.id}`, identifier, control.label)
241
+ inputs.set(identifier, { service, card: { label: control.label, cardId: control.cardId } })
242
+ }
243
+
244
+ const shortcuts = getShortcutsEnabled(this.#platform.config)
245
+ ? getShortcutEntries(this.#deviceModel.shortcuts)
246
+ : []
247
+ this.#shortcutsSignature = getShortcutsSignature(shortcuts)
248
+
249
+ for (const entry of shortcuts) {
250
+ identifier += 1
251
+ const fallbackName = entry.builtInName ?? `Shortcut ${entry.number}`
252
+ const service = this.configureInputSource(`ShortcutInput:${entry.id}`, identifier, fallbackName, Boolean(entry.builtInName))
253
+ const card = toPlayableCard(entry, fallbackName)
254
+ inputs.set(identifier, { service, card })
255
+ if (!entry.builtInName) this.resolveInputName(service, card)
256
+ }
257
+
258
+ // Drop inputs that are no longer configured
259
+ const activeServices = new Set(Array.from(inputs.values(), input => input.service))
260
+ for (const service of [...this.#accessory.services]) {
261
+ const subtype = service.subtype ?? ''
262
+ if (INPUT_SUBTYPE_PREFIXES.some(prefix => subtype.startsWith(prefix)) && !activeServices.has(service)) {
263
+ this.televisionService?.removeLinkedService(service)
264
+ this.#currentServices.delete(service)
265
+ this.#accessory.removeService(service)
266
+ }
267
+ }
268
+
269
+ this.#inputs = inputs
270
+ this.inputSourceService = nowPlaying
271
+ }
272
+
273
+ /**
274
+ * Create or update a single InputSource service linked to the TV.
275
+ * @param {string} subtype
276
+ * @param {number} identifier
277
+ * @param {string} name
278
+ * @param {boolean} [forceName] - Apply the name even if the input already exists
279
+ * @returns {Service}
280
+ */
281
+ configureInputSource (subtype, identifier, name, forceName = false) {
282
+ const { Service, Characteristic } = this.#platform
283
+ const inputName = sanitizeName(name) || `Input ${identifier}`
284
+
285
+ const existing = this.#accessory.getServiceById(Service.InputSource, subtype)
286
+ const service = existing || this.#accessory.addService(Service.InputSource, inputName, subtype)
287
+
288
+ service
289
+ .setCharacteristic(Characteristic.Identifier, identifier)
290
+ .setCharacteristic(Characteristic.InputDeviceType, Characteristic.InputDeviceType.AUDIO_SYSTEM)
291
+ .setCharacteristic(Characteristic.InputSourceType, Characteristic.InputSourceType.OTHER)
292
+ .setCharacteristic(Characteristic.IsConfigured, Characteristic.IsConfigured.CONFIGURED)
293
+
294
+ // Keep a name resolved earlier (e.g. a card title) when re-running setup
295
+ if (!existing || forceName || subtype === 'PlaybackInput' || subtype.startsWith('CardInput:')) {
296
+ service
297
+ .setCharacteristic(Characteristic.Name, inputName)
298
+ .setCharacteristic(Characteristic.ConfiguredName, inputName)
299
+ }
300
+
301
+ if (!existing) {
302
+ service
303
+ .setCharacteristic(Characteristic.CurrentVisibilityState, Characteristic.CurrentVisibilityState.SHOWN)
304
+ .setCharacteristic(Characteristic.TargetVisibilityState, Characteristic.TargetVisibilityState.SHOWN)
305
+ }
306
+
307
+ service
308
+ .getCharacteristic(Characteristic.TargetVisibilityState)
309
+ .onSet((value) => {
310
+ const target = typeof value === 'number' ? value : Number(value)
311
+ const current = target === Characteristic.TargetVisibilityState.SHOWN
312
+ ? Characteristic.CurrentVisibilityState.SHOWN
313
+ : Characteristic.CurrentVisibilityState.HIDDEN
314
+ service.updateCharacteristic(Characteristic.CurrentVisibilityState, current)
315
+ })
316
+
317
+ if (this.televisionService && !this.televisionService.linkedServices.includes(service)) {
318
+ this.televisionService.addLinkedService(service)
319
+ }
320
+
321
+ this.#currentServices.add(service)
322
+ return service
323
+ }
324
+
325
+ /**
326
+ * Rename a shortcut input to its card title once known.
327
+ * @param {Service} service
328
+ * @param {PlayableCard} card
329
+ */
330
+ resolveInputName (service, card) {
331
+ const { Characteristic } = this.#platform
332
+ this.#platform.getCardTitle(card.cardId).then(title => {
333
+ if (!title || !this.#accessory.services.includes(service)) return
334
+ const name = sanitizeName(title)
335
+ if (!name) return
336
+ card.label = name
337
+ service
338
+ .updateCharacteristic(Characteristic.Name, name)
339
+ .updateCharacteristic(Characteristic.ConfiguredName, name)
340
+ }).catch(error => {
341
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to name TV input:`, formatError(error))
342
+ })
343
+ }
344
+
345
+ /**
346
+ * Identifier of the input matching the current card, or "now playing".
347
+ * @returns {number}
348
+ */
349
+ getCurrentInputIdentifier () {
350
+ const cardId = this.#deviceModel.playback.cardId
351
+ if (cardId) {
352
+ for (const [identifier, input] of this.#inputs) {
353
+ if (input.card?.cardId === cardId) return identifier
354
+ }
355
+ }
356
+ return NOW_PLAYING_INPUT_ID
357
+ }
358
+
359
+ /**
360
+ * Setup TelevisionSpeaker service
361
+ */
362
+ setupTelevisionSpeakerService () {
363
+ const { Service, Characteristic } = this.#platform
364
+ const serviceName = sanitizeName(`${this.#accessory.displayName} Audio`)
365
+
366
+ const service = this.#accessory.getService(Service.TelevisionSpeaker) ||
367
+ this.#accessory.addService(Service.TelevisionSpeaker, serviceName)
368
+
369
+ syncServiceNames({ Characteristic, service, name: serviceName })
370
+
371
+ service.addOptionalCharacteristic(Characteristic.Active)
372
+ service.addOptionalCharacteristic(Characteristic.Volume)
373
+ service.addOptionalCharacteristic(Characteristic.VolumeControlType)
374
+ service.addOptionalCharacteristic(Characteristic.VolumeSelector)
375
+
376
+ service
377
+ .getCharacteristic(Characteristic.Mute)
378
+ .onGet(this.getMute.bind(this))
379
+ .onSet(this.setMute.bind(this))
380
+
381
+ service
382
+ .getCharacteristic(Characteristic.Active)
383
+ .onGet(this.getSpeakerActive.bind(this))
384
+
385
+ service
386
+ .getCharacteristic(Characteristic.Volume)
387
+ .setProps({
388
+ minValue: 0,
389
+ maxValue: 100,
390
+ minStep: 1,
391
+ })
392
+ .onGet(this.getVolume.bind(this))
393
+ .onSet(this.setVolume.bind(this))
394
+
395
+ service
396
+ .getCharacteristic(Characteristic.VolumeControlType)
397
+ .updateValue(Characteristic.VolumeControlType.ABSOLUTE)
398
+
399
+ service
400
+ .getCharacteristic(Characteristic.VolumeSelector)
401
+ .onSet(this.setVolumeSelector.bind(this))
402
+
403
+ this.speakerService = service
404
+ this.#currentServices.add(service)
405
+ }
406
+
407
+ /**
408
+ * Setup event listeners for device model updates
409
+ */
410
+ setupEventListeners () {
411
+ this.#listeners.on('statusUpdate', (status, _source, changedFields) => {
412
+ for (const field of changedFields) {
413
+ switch (field) {
414
+ case 'volume':
415
+ this.updateSpeakerVolumeCharacteristics(status.volume)
416
+ break
417
+
418
+ case 'isOnline':
419
+ this.updateTelevisionPlaybackCharacteristics(this.#deviceModel.playback.playbackStatus ?? null)
420
+ this.updateSpeakerActiveCharacteristic(this.#deviceModel.playback.playbackStatus ?? null)
421
+ break
422
+
423
+ case 'firmwareVersion':
424
+ this.updateFirmwareVersionCharacteristic(status.firmwareVersion)
425
+ break
426
+
427
+ // Available but not mapped to Television characteristics
428
+ case 'batteryLevelPercentage':
429
+ case 'isCharging':
430
+ case 'maxVolume':
431
+ case 'temperatureCelsius':
432
+ case 'nightlightMode':
433
+ case 'dayMode':
434
+ case 'cardInsertionState':
435
+ case 'activeCardId':
436
+ case 'powerSource':
437
+ case 'wifiStrength':
438
+ case 'freeDiskSpaceBytes':
439
+ case 'totalDiskSpaceBytes':
440
+ case 'isAudioDeviceConnected':
441
+ case 'isBluetoothAudioConnected':
442
+ case 'ambientLightSensorReading':
443
+ case 'displayBrightness':
444
+ case 'timeFormat':
445
+ case 'uptime':
446
+ case 'updatedAt':
447
+ case 'source':
448
+ break
449
+
450
+ default: {
451
+ /** @type {never} */
452
+ const _exhaustive = field
453
+ this.#log.debug('Unhandled TV status field:', _exhaustive)
454
+ break
455
+ }
456
+ }
457
+ }
458
+ })
459
+
460
+ this.#listeners.on('playbackUpdate', (playback, changedFields) => {
461
+ for (const field of changedFields) {
462
+ switch (field) {
463
+ case 'playbackStatus':
464
+ this.updateTelevisionPlaybackCharacteristics(playback.playbackStatus)
465
+ this.updateSpeakerActiveCharacteristic(playback.playbackStatus)
466
+ break
467
+
468
+ case 'cardId':
469
+ this.updateActiveIdentifierCharacteristic()
470
+ break
471
+
472
+ case 'sleepTimerActive':
473
+ case 'position':
474
+ case 'trackLength':
475
+ case 'cardTitle':
476
+ case 'cardSlug':
477
+ case 'cardCoverImageUrl':
478
+ case 'cardAuthor':
479
+ case 'cardReadBy':
480
+ case 'cardDurationSeconds':
481
+ case 'source':
482
+ case 'trackTitle':
483
+ case 'trackKey':
484
+ case 'chapterTitle':
485
+ case 'chapterKey':
486
+ case 'sleepTimerSeconds':
487
+ case 'streaming':
488
+ case 'updatedAt':
489
+ break
490
+
491
+ default: {
492
+ /** @type {never} */
493
+ const _exhaustive = field
494
+ this.#log.debug('Unhandled TV playback field:', _exhaustive)
495
+ break
496
+ }
497
+ }
498
+ }
499
+ })
500
+
501
+ // Shortcut changes emit configUpdate; rebuild inputs when they change
502
+ this.#listeners.on('configUpdate', () => {
503
+ if (!getShortcutsEnabled(this.#platform.config)) return
504
+ const signature = getShortcutsSignature(getShortcutEntries(this.#deviceModel.shortcuts))
505
+ if (signature === this.#shortcutsSignature) return
506
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Shortcuts changed, updating TV inputs`)
507
+ this.setupInputSources()
508
+ this.updateActiveIdentifierCharacteristic()
509
+ })
510
+
511
+ this.#listeners.on('online', ({ reason: _reason }) => {
512
+ this.updateTelevisionPlaybackCharacteristics(this.#deviceModel.playback.playbackStatus ?? null)
513
+ this.updateSpeakerActiveCharacteristic(this.#deviceModel.playback.playbackStatus ?? null)
514
+ })
515
+
516
+ this.#listeners.on('offline', ({ reason: _reason }) => {
517
+ this.updateTelevisionPlaybackCharacteristics(this.#deviceModel.playback.playbackStatus ?? null)
518
+ this.updateSpeakerActiveCharacteristic(this.#deviceModel.playback.playbackStatus ?? null)
519
+ })
520
+
521
+ this.#listeners.on('error', (error) => {
522
+ const details = formatError(error)
523
+ this.#log.error(`[${this.#device.name}] TV device error:`, details)
524
+ })
525
+ }
526
+
527
+ /**
528
+ * @param { "playing" | "paused" | "stopped" | "loading" | null} playbackStatus
529
+ * @returns {{ current: CharacteristicValue, target: CharacteristicValue }}
530
+ */
531
+ getMediaStateValues (playbackStatus) {
532
+ const { Characteristic } = this.#platform
533
+
534
+ if (playbackStatus === 'playing') {
535
+ return {
536
+ current: Characteristic.CurrentMediaState.PLAY,
537
+ target: Characteristic.TargetMediaState.PLAY,
538
+ }
539
+ }
540
+
541
+ if (playbackStatus === 'paused') {
542
+ return {
543
+ current: Characteristic.CurrentMediaState.PAUSE,
544
+ target: Characteristic.TargetMediaState.PAUSE,
545
+ }
546
+ }
547
+
548
+ if (playbackStatus === 'loading') {
549
+ return {
550
+ current: Characteristic.CurrentMediaState.LOADING,
551
+ target: Characteristic.TargetMediaState.PLAY,
552
+ }
553
+ }
554
+
555
+ return {
556
+ current: Characteristic.CurrentMediaState.STOP,
557
+ target: Characteristic.TargetMediaState.STOP,
558
+ }
559
+ }
560
+
561
+ /**
562
+ * The TV is "on" while the Yoto is online and playing, so turning it off
563
+ * (which pauses) leaves the tile off.
564
+ * @param { "playing" | "paused" | "stopped" | "loading" | null} playbackStatus
565
+ * @param {boolean} isOnline
566
+ * @returns {CharacteristicValue}
567
+ */
568
+ getActiveValue (playbackStatus, isOnline) {
569
+ const { Characteristic } = this.#platform
570
+ if (isOnline && (playbackStatus === 'playing' || playbackStatus === 'loading')) {
571
+ return Characteristic.Active.ACTIVE
572
+ }
573
+
574
+ return Characteristic.Active.INACTIVE
575
+ }
576
+
577
+ /**
578
+ * Get Active state from live playback status
579
+ * @returns {Promise<CharacteristicValue>}
580
+ */
581
+ async getActive () {
582
+ const playbackStatus = this.#deviceModel.playback.playbackStatus ?? null
583
+ const isOnline = this.#deviceModel.status.isOnline
584
+ const active = this.getActiveValue(playbackStatus, isOnline)
585
+ this.#log.debug(
586
+ LOG_PREFIX.ACCESSORY,
587
+ `[${this.#device.name}] Get TV active -> ${active} (${playbackStatus ?? 'unknown'})`
588
+ )
589
+ return active
590
+ }
591
+
592
+ /**
593
+ * Set Active state (play/pause)
594
+ * @param {CharacteristicValue} value
595
+ * @returns {Promise<void>}
596
+ */
597
+ async setActive (value) {
598
+ const { Characteristic } = this.#platform
599
+ const targetValue = typeof value === 'number' ? value : Number(value)
600
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Set TV active:`, value)
601
+
602
+ try {
603
+ if (targetValue === Characteristic.Active.ACTIVE) {
604
+ const request = ++this.#resumeRequest
605
+ await delay(RESUME_DELAY_MS)
606
+ // An input was picked meanwhile; it starts its own card
607
+ if (request !== this.#resumeRequest) return
608
+ await this.#deviceModel.resumeCard()
609
+ return
610
+ }
611
+
612
+ if (targetValue === Characteristic.Active.INACTIVE) {
613
+ await this.#deviceModel.pauseCard()
614
+ }
615
+ } catch (error) {
616
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set TV active:`, formatError(error))
617
+ throw new this.#platform.api.hap.HapStatusError(
618
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
619
+ )
620
+ }
621
+ }
622
+
623
+ /**
624
+ * Get Active Identifier (input matching the current card)
625
+ * @returns {Promise<CharacteristicValue>}
626
+ */
627
+ async getActiveIdentifier () {
628
+ const identifier = this.getCurrentInputIdentifier()
629
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get TV active identifier -> ${identifier}`)
630
+ return identifier
631
+ }
632
+
633
+ /**
634
+ * Set Active Identifier - selecting an input plays its card
635
+ * @param {CharacteristicValue} value
636
+ * @returns {Promise<void>}
637
+ */
638
+ async setActiveIdentifier (value) {
639
+ const identifier = typeof value === 'number' ? value : Number(value)
640
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Set TV active identifier:`, identifier)
641
+
642
+ const card = this.#inputs.get(identifier)?.card
643
+ if (!card) {
644
+ // "Now playing" (or an unknown input): nothing to start
645
+ this.updateActiveIdentifierCharacteristic()
646
+ return
647
+ }
648
+
649
+ // Cancel a pending resume so the previous card doesn't play first
650
+ this.#resumeRequest++
651
+
652
+ if (!this.#deviceModel.status.isOnline) {
653
+ this.#log.warn(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Input skipped (offline): ${card.label}`)
654
+ this.updateActiveIdentifierCharacteristic()
655
+ throw new this.#platform.api.hap.HapStatusError(
656
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
657
+ )
658
+ }
659
+
660
+ try {
661
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Play TV input: ${card.label} (${card.cardId})`)
662
+ await this.#deviceModel.startCard(getCardStartOptions(card))
663
+ } catch (error) {
664
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to play ${card.cardId}:`, formatError(error))
665
+ this.updateActiveIdentifierCharacteristic()
666
+ throw new this.#platform.api.hap.HapStatusError(
667
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
668
+ )
669
+ }
670
+ }
671
+
672
+ /**
673
+ * Handle remote key input
674
+ * @param {CharacteristicValue} value
675
+ * @returns {Promise<void>}
676
+ */
677
+ async setRemoteKey (value) {
678
+ const { Characteristic } = this.#platform
679
+ const keyValue = typeof value === 'number' ? value : Number(value)
680
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Remote key:`, keyValue)
681
+
682
+ try {
683
+ switch (keyValue) {
684
+ case Characteristic.RemoteKey.PLAY_PAUSE:
685
+ case Characteristic.RemoteKey.SELECT: {
686
+ const status = this.#deviceModel.playback.playbackStatus
687
+ if (status === 'playing') {
688
+ await this.#deviceModel.pauseCard()
689
+ } else {
690
+ await this.#deviceModel.resumeCard()
691
+ }
692
+ break
693
+ }
694
+ // Yoto's MQTT API has no next/previous track command, so arrows, back
695
+ // and info keys have nothing to map to.
696
+ default:
697
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Unhandled remote key:`, keyValue)
698
+ break
699
+ }
700
+ } catch (error) {
701
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to handle remote key:`, formatError(error))
702
+ throw new this.#platform.api.hap.HapStatusError(
703
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
704
+ )
705
+ }
706
+ }
707
+
708
+ /**
709
+ * Get current media state from live playback state
710
+ * @returns {Promise<CharacteristicValue>}
711
+ */
712
+ async getCurrentMediaState () {
713
+ const playbackStatus = this.#deviceModel.playback.playbackStatus ?? null
714
+ const current = this.getMediaStateValues(playbackStatus).current
715
+ this.#log.debug(
716
+ LOG_PREFIX.ACCESSORY,
717
+ `[${this.#device.name}] Get TV current media state -> ${current} (${playbackStatus ?? 'unknown'})`
718
+ )
719
+ return current
720
+ }
721
+
722
+ /**
723
+ * Get target media state (follows current state)
724
+ * @returns {Promise<CharacteristicValue>}
725
+ */
726
+ async getTargetMediaState () {
727
+ const playbackStatus = this.#deviceModel.playback.playbackStatus ?? null
728
+ const target = this.getMediaStateValues(playbackStatus).target
729
+ this.#log.debug(
730
+ LOG_PREFIX.ACCESSORY,
731
+ `[${this.#device.name}] Get TV target media state -> ${target} (${playbackStatus ?? 'unknown'})`
732
+ )
733
+ return target
734
+ }
735
+
736
+ /**
737
+ * Set target media state (play/pause/stop)
738
+ * @param {CharacteristicValue} value - Target state
739
+ * @returns {Promise<void>}
740
+ */
741
+ async setTargetMediaState (value) {
742
+ const { Characteristic } = this.#platform
743
+ const targetValue = typeof value === 'number' ? value : Number(value)
744
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Set TV target media state:`, value)
745
+
746
+ try {
747
+ if (targetValue === Characteristic.TargetMediaState.PLAY) {
748
+ await this.#deviceModel.resumeCard()
749
+ return
750
+ }
751
+
752
+ if (targetValue === Characteristic.TargetMediaState.PAUSE) {
753
+ await this.#deviceModel.pauseCard()
754
+ return
755
+ }
756
+
757
+ if (targetValue === Characteristic.TargetMediaState.STOP) {
758
+ // Pause rather than stop so playback can be resumed (matches the SmartSpeaker)
759
+ await this.#deviceModel.pauseCard()
760
+ }
761
+ } catch (error) {
762
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set TV media state:`, formatError(error))
763
+ throw new this.#platform.api.hap.HapStatusError(
764
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
765
+ )
766
+ }
767
+ }
768
+
769
+ /**
770
+ * Get volume level as percentage (mapped from 0-16 steps)
771
+ * @returns {Promise<CharacteristicValue>}
772
+ */
773
+ async getVolume () {
774
+ const volumeSteps = this.#deviceModel.status.volume
775
+ const percent = stepsToPercent(volumeSteps)
776
+ this.#log.debug(
777
+ LOG_PREFIX.ACCESSORY,
778
+ `[${this.#device.name}] Get TV volume -> ${percent} (rawSteps=${volumeSteps})`
779
+ )
780
+ return percent
781
+ }
782
+
783
+ /**
784
+ * Set volume level as percentage (mapped to 0-16 steps)
785
+ * @param {CharacteristicValue} value - Volume level percent
786
+ * @returns {Promise<void>}
787
+ */
788
+ async setVolume (value) {
789
+ const deviceModel = this.#deviceModel
790
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Set TV volume:`, value)
791
+
792
+ const requestedPercent = typeof value === 'number' ? value : Number(value)
793
+ if (!Number.isFinite(requestedPercent)) {
794
+ throw new this.#platform.api.hap.HapStatusError(
795
+ this.#platform.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST
796
+ )
797
+ }
798
+
799
+ const normalizedPercent = clampPercent(requestedPercent)
800
+ const requestedSteps = percentToSteps(normalizedPercent)
801
+ const steps = clampSteps(requestedSteps)
802
+
803
+ if (steps > 0) {
804
+ this.#lastNonZeroVolume = stepsToPercent(steps)
805
+ }
806
+
807
+ try {
808
+ await setDeviceVolume(deviceModel, steps)
809
+ this.updateSpeakerVolumeCharacteristics(steps)
810
+ } catch (error) {
811
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set TV volume:`, formatError(error))
812
+ throw new this.#platform.api.hap.HapStatusError(
813
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
814
+ )
815
+ }
816
+ }
817
+
818
+ /**
819
+ * Get mute state (derived from volume === 0)
820
+ * @returns {Promise<CharacteristicValue>}
821
+ */
822
+ async getMute () {
823
+ const isMuted = this.#deviceModel.status.volume === 0
824
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get TV mute -> ${isMuted}`)
825
+ return isMuted
826
+ }
827
+
828
+ /**
829
+ * Set mute state
830
+ * @param {CharacteristicValue} value - Mute state
831
+ * @returns {Promise<void>}
832
+ */
833
+ async setMute (value) {
834
+ const isMuted = Boolean(value)
835
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Set TV mute:`, isMuted)
836
+
837
+ if (isMuted) {
838
+ await this.setVolume(0)
839
+ return
840
+ }
841
+
842
+ await this.setVolume(this.#lastNonZeroVolume)
843
+ }
844
+
845
+ /**
846
+ * Get speaker active state (mirrors playback)
847
+ * @returns {Promise<CharacteristicValue>}
848
+ */
849
+ async getSpeakerActive () {
850
+ const playbackStatus = this.#deviceModel.playback.playbackStatus ?? null
851
+ const isOnline = this.#deviceModel.status.isOnline
852
+ const active = this.getActiveValue(playbackStatus, isOnline)
853
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get TV speaker active -> ${active}`)
854
+ return active
855
+ }
856
+
857
+ /**
858
+ * Handle volume selector (increment/decrement)
859
+ * @param {CharacteristicValue} value
860
+ * @returns {Promise<void>}
861
+ */
862
+ async setVolumeSelector (value) {
863
+ const { Characteristic } = this.#platform
864
+ const selector = typeof value === 'number' ? value : Number(value)
865
+ const currentSteps = stepsFromVolumeValue(this.#deviceModel.status.volume)
866
+ const delta = selector === Characteristic.VolumeSelector.INCREMENT ? 1 : -1
867
+ const nextSteps = clampSteps(currentSteps + delta)
868
+ this.#log.debug(
869
+ LOG_PREFIX.ACCESSORY,
870
+ `[${this.#device.name}] Set TV volume selector:`,
871
+ selector,
872
+ `-> ${nextSteps}`
873
+ )
874
+
875
+ try {
876
+ await setDeviceVolume(this.#deviceModel, nextSteps)
877
+ this.updateSpeakerVolumeCharacteristics(nextSteps)
878
+ } catch (error) {
879
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set TV volume selector:`, formatError(error))
880
+ throw new this.#platform.api.hap.HapStatusError(
881
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
882
+ )
883
+ }
884
+ }
885
+
886
+ /**
887
+ * Update Television Active and media state characteristics
888
+ * @param { "playing" | "paused" | "stopped" | "loading" | null} playbackStatus
889
+ */
890
+ updateTelevisionPlaybackCharacteristics (playbackStatus) {
891
+ if (!this.televisionService) return
892
+
893
+ const { Characteristic } = this.#platform
894
+ const isOnline = this.#deviceModel.status.isOnline
895
+ const active = this.getActiveValue(playbackStatus, isOnline)
896
+ const { current, target } = this.getMediaStateValues(playbackStatus)
897
+
898
+ this.televisionService
899
+ .getCharacteristic(Characteristic.Active)
900
+ .updateValue(active)
901
+
902
+ this.televisionService
903
+ .getCharacteristic(Characteristic.ActiveIdentifier)
904
+ .updateValue(this.getCurrentInputIdentifier())
905
+
906
+ this.televisionService
907
+ .getCharacteristic(Characteristic.CurrentMediaState)
908
+ .updateValue(current)
909
+
910
+ this.televisionService
911
+ .getCharacteristic(Characteristic.TargetMediaState)
912
+ .updateValue(target)
913
+ }
914
+
915
+ /**
916
+ * Update ActiveIdentifier to the input matching the current card
917
+ */
918
+ updateActiveIdentifierCharacteristic () {
919
+ if (!this.televisionService) return
920
+ this.televisionService
921
+ .getCharacteristic(this.#platform.Characteristic.ActiveIdentifier)
922
+ .updateValue(this.getCurrentInputIdentifier())
923
+ }
924
+
925
+ /**
926
+ * Update TV speaker volume + mute characteristics
927
+ * @param {number} volumeSteps - Volume level (0-16)
928
+ */
929
+ updateSpeakerVolumeCharacteristics (volumeSteps) {
930
+ if (!this.speakerService) return
931
+
932
+ const percent = stepsToPercent(volumeSteps)
933
+ const isMuted = clampSteps(volumeSteps) === 0
934
+
935
+ this.speakerService
936
+ .getCharacteristic(this.#platform.Characteristic.Volume)
937
+ .updateValue(percent)
938
+
939
+ this.speakerService
940
+ .getCharacteristic(this.#platform.Characteristic.Mute)
941
+ .updateValue(isMuted)
942
+ }
943
+
944
+ /**
945
+ * Update TV speaker active characteristic
946
+ * @param { "playing" | "paused" | "stopped" | "loading" | null} playbackStatus
947
+ */
948
+ updateSpeakerActiveCharacteristic (playbackStatus) {
949
+ if (!this.speakerService) return
950
+
951
+ const { Characteristic } = this.#platform
952
+ const isOnline = this.#deviceModel.status.isOnline
953
+ const active = this.getActiveValue(playbackStatus, isOnline)
954
+
955
+ this.speakerService
956
+ .getCharacteristic(Characteristic.Active)
957
+ .updateValue(active)
958
+ }
959
+
960
+ /**
961
+ * Update firmware version characteristic
962
+ * @param {string} firmwareVersion - Firmware version
963
+ */
964
+ updateFirmwareVersionCharacteristic (firmwareVersion) {
965
+ const { Service, Characteristic } = this.#platform
966
+ const infoService = this.#accessory.getService(Service.AccessoryInformation)
967
+ if (!infoService) return
968
+
969
+ infoService.setCharacteristic(
970
+ Characteristic.FirmwareRevision,
971
+ firmwareVersion
972
+ )
973
+ }
974
+
975
+ /**
976
+ * Stop accessory - cleanup event listeners
977
+ * @returns {Promise<void>}
978
+ */
979
+ async stop () {
980
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Stopping TV playback for ${this.#device.name}`)
981
+
982
+ // Only remove our own listeners; the device model is shared
983
+ this.#listeners.removeAll()
984
+ // Drop any pending resume
985
+ this.#resumeRequest++
986
+ }
987
+ }