@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.
- package/LICENSE +22 -0
- package/README.md +131 -0
- package/config.schema.cjs +11 -0
- package/config.schema.json +231 -0
- package/homebridge-ui/public/client.js +339 -0
- package/homebridge-ui/public/index.html +125 -0
- package/homebridge-ui/server.js +247 -0
- package/index.js +16 -0
- package/lib/accessory.js +2350 -0
- package/lib/card-control-accessory.js +212 -0
- package/lib/card-controls.js +75 -0
- package/lib/platform.js +1019 -0
- package/lib/service-config.js +47 -0
- package/lib/settings.js +56 -0
- package/lib/shortcuts.js +131 -0
- package/lib/speaker-accessory.js +551 -0
- package/lib/sync-service-names.js +39 -0
- package/lib/television-accessory.js +987 -0
- package/lib/utils/error-format.js +29 -0
- package/lib/utils/get-boolean-setting.js +8 -0
- package/lib/utils/get-trimmed-string.js +7 -0
- package/lib/utils/listener-group.js +45 -0
- package/lib/utils/oauth.js +70 -0
- package/lib/utils/sanitize-name.js +49 -0
- package/lib/utils/set-device-volume.js +22 -0
- package/lib/utils/status-scope-fallback.js +48 -0
- package/lib/utils/token-config.js +59 -0
- package/lib/utils/volume.js +78 -0
- package/package.json +76 -0
|
@@ -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
|
+
}
|