@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,551 @@
1
+ /**
2
+ * @fileoverview Yoto SmartSpeaker 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
+
12
+ import {
13
+ DEFAULT_MANUFACTURER,
14
+ DEFAULT_MODEL,
15
+ LOG_PREFIX,
16
+ } from './settings.js'
17
+ import { sanitizeName } from './utils/sanitize-name.js'
18
+ import { syncServiceNames } from './sync-service-names.js'
19
+ import { formatError } from './utils/error-format.js'
20
+ import { ListenerGroup } from './utils/listener-group.js'
21
+ import { setDeviceVolume } from './utils/set-device-volume.js'
22
+ import {
23
+ clampPercent,
24
+ clampSteps,
25
+ percentToSteps,
26
+ stepsToPercent,
27
+ } from './utils/volume.js'
28
+
29
+ /**
30
+ * Yoto SmartSpeaker Accessory Handler (external)
31
+ * Manages SmartSpeaker service and characteristics for a single Yoto player.
32
+ */
33
+ export class YotoSpeakerAccessory {
34
+ /** @type {YotoPlatform} */ #platform
35
+ /** @type {PlatformAccessory<YotoAccessoryContext>} */ #accessory
36
+ /** @type {YotoDeviceModel} */ #deviceModel
37
+ /** @type {Logger} */ #log
38
+ /** @type {YotoDevice} */ #device
39
+ /** @type {Service | undefined} */ speakerService
40
+ /** @type {number} */ #lastNonZeroVolume = 50
41
+ /** @type {Set<Service>} */ #currentServices = new Set()
42
+ /** @type {ListenerGroup<YotoDeviceModelEventMap>} */ #listeners
43
+
44
+ /**
45
+ * @param {Object} params
46
+ * @param {YotoPlatform} params.platform - Platform instance
47
+ * @param {PlatformAccessory<YotoAccessoryContext>} params.accessory - Platform accessory
48
+ * @param {YotoDeviceModel} params.deviceModel - Yoto device model with live state
49
+ */
50
+ constructor ({ platform, accessory, deviceModel }) {
51
+ this.#platform = platform
52
+ this.#accessory = accessory
53
+ this.#deviceModel = deviceModel
54
+ this.#listeners = new ListenerGroup(deviceModel)
55
+ this.#log = platform.log
56
+
57
+ this.#device = accessory.context.device
58
+ this.#currentServices = new Set()
59
+ }
60
+
61
+ /**
62
+ * Setup accessory - create services and setup event listeners
63
+ * @returns {Promise<void>}
64
+ */
65
+ async setup () {
66
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Setting up speaker for ${this.#device.name}`)
67
+
68
+ this.#currentServices.clear()
69
+
70
+ this.setupAccessoryInformation()
71
+ this.setupSmartSpeakerService()
72
+
73
+ // Iterate a copy: removeService splices the live array
74
+ for (const service of [...this.#accessory.services]) {
75
+ if (service.UUID !== this.#platform.Service.AccessoryInformation.UUID &&
76
+ !this.#currentServices.has(service)) {
77
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Removing stale speaker service: ${service.displayName || service.UUID}`)
78
+ this.#accessory.removeService(service)
79
+ }
80
+ }
81
+
82
+ this.setupEventListeners()
83
+
84
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `✓ ${this.#device.name} speaker ready`)
85
+ }
86
+
87
+ /**
88
+ * Setup AccessoryInformation service
89
+ */
90
+ setupAccessoryInformation () {
91
+ const { Service, Characteristic } = this.#platform
92
+ const service = this.#accessory.getService(Service.AccessoryInformation) ||
93
+ this.#accessory.addService(Service.AccessoryInformation)
94
+ const displayName = sanitizeName(this.#accessory.displayName)
95
+ const nameCharacteristic = service.getCharacteristic(Characteristic.Name)
96
+ const configuredCharacteristic = service.getCharacteristic(Characteristic.ConfiguredName)
97
+ const previousName = nameCharacteristic.value
98
+ const configuredName = configuredCharacteristic.value
99
+
100
+ const hardwareRevision = [
101
+ this.#device.generation,
102
+ this.#device.formFactor,
103
+ ].filter(Boolean).join(' ') || 'Unknown'
104
+
105
+ const model = this.#device.deviceFamily || this.#device.deviceType || DEFAULT_MODEL
106
+
107
+ service
108
+ .setCharacteristic(Characteristic.Name, displayName)
109
+ .setCharacteristic(Characteristic.Manufacturer, DEFAULT_MANUFACTURER)
110
+ .setCharacteristic(Characteristic.Model, model)
111
+ .setCharacteristic(Characteristic.SerialNumber, this.#device.deviceId)
112
+ .setCharacteristic(Characteristic.HardwareRevision, hardwareRevision)
113
+
114
+ if (typeof configuredName !== 'string' || configuredName === previousName) {
115
+ service.setCharacteristic(Characteristic.ConfiguredName, displayName)
116
+ }
117
+
118
+ if (this.#deviceModel.status.firmwareVersion) {
119
+ service.setCharacteristic(
120
+ Characteristic.FirmwareRevision,
121
+ this.#deviceModel.status.firmwareVersion
122
+ )
123
+ }
124
+
125
+ this.#currentServices.add(service)
126
+ }
127
+
128
+ /**
129
+ * Setup SmartSpeaker service (PRIMARY)
130
+ */
131
+ setupSmartSpeakerService () {
132
+ const { Service, Characteristic } = this.#platform
133
+ const serviceName = sanitizeName(this.#accessory.displayName)
134
+
135
+ const service = this.#accessory.getService(Service.SmartSpeaker) ||
136
+ this.#accessory.addService(Service.SmartSpeaker, serviceName)
137
+
138
+ service.setPrimaryService(true)
139
+
140
+ syncServiceNames({ Characteristic, service, name: serviceName })
141
+
142
+ service
143
+ .getCharacteristic(Characteristic.CurrentMediaState)
144
+ .onGet(this.getCurrentMediaState.bind(this))
145
+
146
+ service
147
+ .getCharacteristic(Characteristic.TargetMediaState)
148
+ .onGet(this.getTargetMediaState.bind(this))
149
+ .onSet(this.setTargetMediaState.bind(this))
150
+
151
+ // Advertise AirPlay support so Home keeps transport controls enabled.
152
+ // AirPlayEnable is a writable uint8 (0/1); ignore writes and always report enabled.
153
+ service
154
+ .getCharacteristic(Characteristic.AirPlayEnable)
155
+ .onGet(() => 1)
156
+ .onSet(() => {
157
+ service.getCharacteristic(Characteristic.AirPlayEnable).updateValue(1)
158
+ })
159
+ .updateValue(1)
160
+
161
+ service
162
+ .getCharacteristic(Characteristic.Volume)
163
+ .setProps({
164
+ minValue: 0,
165
+ maxValue: 100,
166
+ minStep: 1,
167
+ })
168
+ .onGet(this.getVolume.bind(this))
169
+ .onSet(this.setVolume.bind(this))
170
+
171
+ service
172
+ .getCharacteristic(Characteristic.Mute)
173
+ .onGet(this.getMute.bind(this))
174
+ .onSet(this.setMute.bind(this))
175
+
176
+ // StatusActive isn't in the SmartSpeaker spec; declare it optional to avoid HAP warnings
177
+ const statusActiveUuid = Characteristic.StatusActive.UUID
178
+ const hasStatusActive = service.characteristics.some(c => c.UUID === statusActiveUuid)
179
+ const hasStatusActiveOptional = service.optionalCharacteristics.some(c => c.UUID === statusActiveUuid)
180
+ if (!hasStatusActive && !hasStatusActiveOptional) {
181
+ service.addOptionalCharacteristic(Characteristic.StatusActive)
182
+ }
183
+
184
+ service
185
+ .getCharacteristic(Characteristic.StatusActive)
186
+ .onGet(this.getStatusActive.bind(this))
187
+
188
+ this.speakerService = service
189
+ this.#currentServices.add(service)
190
+ }
191
+
192
+ /**
193
+ * Setup event listeners for device model updates
194
+ */
195
+ setupEventListeners () {
196
+ this.#listeners.on('statusUpdate', (status, _source, changedFields) => {
197
+ for (const field of changedFields) {
198
+ switch (field) {
199
+ case 'volume':
200
+ this.updateVolumeCharacteristic(status.volume)
201
+ break
202
+
203
+ case 'isOnline':
204
+ this.updateOnlineStatusCharacteristic(status.isOnline)
205
+ break
206
+
207
+ case 'firmwareVersion':
208
+ this.updateFirmwareVersionCharacteristic(status.firmwareVersion)
209
+ break
210
+
211
+ // Available but not mapped to SmartSpeaker characteristics
212
+ case 'batteryLevelPercentage':
213
+ case 'isCharging':
214
+ case 'maxVolume':
215
+ case 'temperatureCelsius':
216
+ case 'nightlightMode':
217
+ case 'dayMode':
218
+ case 'cardInsertionState':
219
+ case 'activeCardId':
220
+ case 'powerSource':
221
+ case 'wifiStrength':
222
+ case 'freeDiskSpaceBytes':
223
+ case 'totalDiskSpaceBytes':
224
+ case 'isAudioDeviceConnected':
225
+ case 'isBluetoothAudioConnected':
226
+ case 'ambientLightSensorReading':
227
+ case 'displayBrightness':
228
+ case 'timeFormat':
229
+ case 'uptime':
230
+ case 'updatedAt':
231
+ case 'source':
232
+ break
233
+
234
+ default: {
235
+ /** @type {never} */
236
+ const _exhaustive = field
237
+ this.#log.debug('Unhandled speaker status field:', _exhaustive)
238
+ break
239
+ }
240
+ }
241
+ }
242
+ })
243
+
244
+ this.#listeners.on('playbackUpdate', (playback, changedFields) => {
245
+ for (const field of changedFields) {
246
+ switch (field) {
247
+ case 'playbackStatus':
248
+ this.updateSmartSpeakerMediaStateCharacteristic(playback.playbackStatus)
249
+ break
250
+
251
+ case 'sleepTimerActive':
252
+ case 'position':
253
+ case 'trackLength':
254
+ case 'cardId':
255
+ case 'cardTitle':
256
+ case 'cardSlug':
257
+ case 'cardCoverImageUrl':
258
+ case 'cardAuthor':
259
+ case 'cardReadBy':
260
+ case 'cardDurationSeconds':
261
+ case 'source':
262
+ case 'trackTitle':
263
+ case 'trackKey':
264
+ case 'chapterTitle':
265
+ case 'chapterKey':
266
+ case 'sleepTimerSeconds':
267
+ case 'streaming':
268
+ case 'updatedAt':
269
+ break
270
+
271
+ default: {
272
+ /** @type {never} */
273
+ const _exhaustive = field
274
+ this.#log.debug('Unhandled speaker playback field:', _exhaustive)
275
+ break
276
+ }
277
+ }
278
+ }
279
+ })
280
+
281
+ this.#listeners.on('online', () => {
282
+ this.updateOnlineStatusCharacteristic(true)
283
+ })
284
+
285
+ this.#listeners.on('offline', () => {
286
+ this.updateOnlineStatusCharacteristic(false)
287
+ })
288
+
289
+ this.#listeners.on('error', (error) => {
290
+ const details = formatError(error)
291
+ this.#log.error(`[${this.#device.name}] Speaker device error:`, details)
292
+ })
293
+ }
294
+
295
+ /**
296
+ * @param { "playing" | "paused" | "stopped" | "loading" | null} playbackStatus
297
+ * @returns {{ current: CharacteristicValue, target: CharacteristicValue }}
298
+ */
299
+ getMediaStateValues (playbackStatus) {
300
+ const { Characteristic } = this.#platform
301
+
302
+ if (playbackStatus === 'playing') {
303
+ return {
304
+ current: Characteristic.CurrentMediaState.PLAY,
305
+ target: Characteristic.TargetMediaState.PLAY,
306
+ }
307
+ }
308
+
309
+ if (playbackStatus === 'paused') {
310
+ return {
311
+ current: Characteristic.CurrentMediaState.PAUSE,
312
+ target: Characteristic.TargetMediaState.PAUSE,
313
+ }
314
+ }
315
+
316
+ if (playbackStatus === 'loading') {
317
+ return {
318
+ current: Characteristic.CurrentMediaState.LOADING,
319
+ target: Characteristic.TargetMediaState.PLAY,
320
+ }
321
+ }
322
+
323
+ return {
324
+ current: Characteristic.CurrentMediaState.STOP,
325
+ target: Characteristic.TargetMediaState.STOP,
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Get current media state from live playback state
331
+ * @returns {Promise<CharacteristicValue>}
332
+ */
333
+ async getCurrentMediaState () {
334
+ const playbackStatus = this.#deviceModel.playback.playbackStatus ?? null
335
+ const current = this.getMediaStateValues(playbackStatus).current
336
+ this.#log.debug(
337
+ LOG_PREFIX.ACCESSORY,
338
+ `[${this.#device.name}] Get current media state -> ${current} (${playbackStatus ?? 'unknown'})`
339
+ )
340
+ return current
341
+ }
342
+
343
+ /**
344
+ * Get target media state (follows current state)
345
+ * @returns {Promise<CharacteristicValue>}
346
+ */
347
+ async getTargetMediaState () {
348
+ const playbackStatus = this.#deviceModel.playback.playbackStatus ?? null
349
+ const target = this.getMediaStateValues(playbackStatus).target
350
+ this.#log.debug(
351
+ LOG_PREFIX.ACCESSORY,
352
+ `[${this.#device.name}] Get target media state -> ${target} (${playbackStatus ?? 'unknown'})`
353
+ )
354
+ return target
355
+ }
356
+
357
+ /**
358
+ * Set target media state (play/pause/stop)
359
+ * @param {CharacteristicValue} value - Target state
360
+ * @returns {Promise<void>}
361
+ */
362
+ async setTargetMediaState (value) {
363
+ const { Characteristic } = this.#platform
364
+ const targetValue = typeof value === 'number' ? value : Number(value)
365
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Set target media state:`, value)
366
+
367
+ try {
368
+ if (targetValue === Characteristic.TargetMediaState.PLAY) {
369
+ await this.#deviceModel.resumeCard()
370
+ return
371
+ }
372
+
373
+ if (targetValue === Characteristic.TargetMediaState.PAUSE) {
374
+ await this.#deviceModel.pauseCard()
375
+ return
376
+ }
377
+
378
+ if (targetValue === Characteristic.TargetMediaState.STOP) {
379
+ // Pause rather than stop so playback can be resumed (matches the TV accessory)
380
+ await this.#deviceModel.pauseCard()
381
+ }
382
+ } catch (error) {
383
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set media state:`, formatError(error))
384
+ throw new this.#platform.api.hap.HapStatusError(
385
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
386
+ )
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Get volume level as percentage (mapped from 0-16 steps)
392
+ * @returns {Promise<CharacteristicValue>}
393
+ */
394
+ async getVolume () {
395
+ const volumeSteps = this.#deviceModel.status.volume
396
+ const percent = stepsToPercent(volumeSteps)
397
+ this.#log.debug(
398
+ LOG_PREFIX.ACCESSORY,
399
+ `[${this.#device.name}] Get speaker volume -> ${percent} (rawSteps=${volumeSteps})`
400
+ )
401
+ return percent
402
+ }
403
+
404
+ /**
405
+ * Set volume level as percentage (mapped to 0-16 steps)
406
+ * @param {CharacteristicValue} value - Volume level percent
407
+ * @returns {Promise<void>}
408
+ */
409
+ async setVolume (value) {
410
+ const deviceModel = this.#deviceModel
411
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Set speaker volume:`, value)
412
+
413
+ const requestedPercent = typeof value === 'number' ? value : Number(value)
414
+ if (!Number.isFinite(requestedPercent)) {
415
+ throw new this.#platform.api.hap.HapStatusError(
416
+ this.#platform.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST
417
+ )
418
+ }
419
+
420
+ const normalizedPercent = clampPercent(requestedPercent)
421
+ const requestedSteps = percentToSteps(normalizedPercent)
422
+ const steps = clampSteps(requestedSteps)
423
+
424
+ if (steps > 0) {
425
+ this.#lastNonZeroVolume = stepsToPercent(steps)
426
+ }
427
+
428
+ try {
429
+ await setDeviceVolume(deviceModel, steps)
430
+ this.updateVolumeCharacteristic(steps)
431
+ } catch (error) {
432
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set speaker volume:`, formatError(error))
433
+ throw new this.#platform.api.hap.HapStatusError(
434
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
435
+ )
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Get mute state (derived from volume === 0)
441
+ * @returns {Promise<CharacteristicValue>}
442
+ */
443
+ async getMute () {
444
+ const isMuted = this.#deviceModel.status.volume === 0
445
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get speaker mute -> ${isMuted}`)
446
+ return isMuted
447
+ }
448
+
449
+ /**
450
+ * Set mute state
451
+ * @param {CharacteristicValue} value - Mute state
452
+ * @returns {Promise<void>}
453
+ */
454
+ async setMute (value) {
455
+ const isMuted = Boolean(value)
456
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Set speaker mute:`, isMuted)
457
+
458
+ if (isMuted) {
459
+ await this.setVolume(0)
460
+ return
461
+ }
462
+
463
+ await this.setVolume(this.#lastNonZeroVolume)
464
+ }
465
+
466
+ /**
467
+ * Get status active (online/offline)
468
+ * @returns {Promise<CharacteristicValue>}
469
+ */
470
+ async getStatusActive () {
471
+ const isOnline = this.#deviceModel.status.isOnline
472
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get speaker status active -> ${isOnline}`)
473
+ return isOnline
474
+ }
475
+
476
+ /**
477
+ * Update online status characteristic
478
+ * @param {boolean} isOnline - Online status
479
+ */
480
+ updateOnlineStatusCharacteristic (isOnline) {
481
+ if (!this.speakerService) return
482
+
483
+ this.speakerService
484
+ .getCharacteristic(this.#platform.Characteristic.StatusActive)
485
+ .updateValue(isOnline)
486
+ }
487
+
488
+ /**
489
+ * Update SmartSpeaker media state characteristics
490
+ * @param { "playing" | "paused" | "stopped" | "loading" | null} playbackStatus - Playback status
491
+ */
492
+ updateSmartSpeakerMediaStateCharacteristic (playbackStatus) {
493
+ if (!this.speakerService) return
494
+
495
+ const { Characteristic } = this.#platform
496
+ const { current, target } = this.getMediaStateValues(playbackStatus)
497
+
498
+ this.speakerService
499
+ .getCharacteristic(Characteristic.CurrentMediaState)
500
+ .updateValue(current)
501
+
502
+ this.speakerService
503
+ .getCharacteristic(Characteristic.TargetMediaState)
504
+ .updateValue(target)
505
+ }
506
+
507
+ /**
508
+ * Update volume and mute characteristics
509
+ * @param {number} volumeSteps - Volume level (0-16)
510
+ */
511
+ updateVolumeCharacteristic (volumeSteps) {
512
+ if (!this.speakerService) return
513
+
514
+ const percent = stepsToPercent(volumeSteps)
515
+ const isMuted = clampSteps(volumeSteps) === 0
516
+
517
+ this.speakerService
518
+ .getCharacteristic(this.#platform.Characteristic.Volume)
519
+ .updateValue(percent)
520
+
521
+ this.speakerService
522
+ .getCharacteristic(this.#platform.Characteristic.Mute)
523
+ .updateValue(isMuted)
524
+ }
525
+
526
+ /**
527
+ * Update firmware version characteristic
528
+ * @param {string} firmwareVersion - Firmware version
529
+ */
530
+ updateFirmwareVersionCharacteristic (firmwareVersion) {
531
+ const { Service, Characteristic } = this.#platform
532
+ const infoService = this.#accessory.getService(Service.AccessoryInformation)
533
+ if (!infoService) return
534
+
535
+ infoService.setCharacteristic(
536
+ Characteristic.FirmwareRevision,
537
+ firmwareVersion
538
+ )
539
+ }
540
+
541
+ /**
542
+ * Stop accessory - cleanup event listeners
543
+ * @returns {Promise<void>}
544
+ */
545
+ async stop () {
546
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Stopping speaker for ${this.#device.name}`)
547
+
548
+ // Only remove our own listeners; the device model is shared
549
+ this.#listeners.removeAll()
550
+ }
551
+ }
@@ -0,0 +1,39 @@
1
+ // Use syncServiceNames for every visible service so HomeKit labels stay stable.
2
+ // Exceptions: AccessoryInformation (named in platform) and Battery (set Characteristic.Name only).
3
+ /** @import { Service, Characteristic } from 'homebridge' */
4
+ import { sanitizeName } from './utils/sanitize-name.js'
5
+
6
+ /**
7
+ * Apply HomeKit-visible naming to a service.
8
+ *
9
+ * We set both `Name` and `ConfiguredName` on every service we manage so HomeKit tiles are consistently labeled.
10
+ *
11
+ * @param {object} params
12
+ * @param {Service} params.service
13
+ * @param {string} params.name
14
+ * @param {typeof Characteristic} params.Characteristic
15
+ * @returns {void}
16
+ */
17
+ export function syncServiceNames ({
18
+ Characteristic,
19
+ service,
20
+ name
21
+ }) {
22
+ const sanitizedName = sanitizeName(name)
23
+ service.displayName = sanitizedName
24
+
25
+ service.updateCharacteristic(Characteristic.Name, sanitizedName)
26
+
27
+ // Add ConfiguredName when missing so we avoid HAP warnings on update.
28
+ const configuredNameUuid = Characteristic.ConfiguredName.UUID
29
+ const hasConfiguredNameCharacteristic = service.characteristics
30
+ .some((characteristic) => characteristic.UUID === configuredNameUuid)
31
+ const hasConfiguredNameOptional = service.optionalCharacteristics
32
+ .some((characteristic) => characteristic.UUID === configuredNameUuid)
33
+
34
+ if (!hasConfiguredNameCharacteristic && !hasConfiguredNameOptional) {
35
+ service.addOptionalCharacteristic(Characteristic.ConfiguredName)
36
+ }
37
+
38
+ service.updateCharacteristic(Characteristic.ConfiguredName, sanitizedName)
39
+ }