homebridge-yoto 0.0.39 → 0.0.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,491 @@
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 { YotoDevice } from 'yoto-nodejs-client/lib/api-endpoints/devices.js' */
9
+ /** @import { YotoAccessoryContext } from './platform.js' */
10
+
11
+ import {
12
+ DEFAULT_MANUFACTURER,
13
+ DEFAULT_MODEL,
14
+ LOG_PREFIX,
15
+ } from './constants.js'
16
+ import { sanitizeName } from './sanitize-name.js'
17
+ import { syncServiceNames } from './sync-service-names.js'
18
+
19
+ /**
20
+ * Yoto SmartSpeaker Accessory Handler (external)
21
+ * Manages SmartSpeaker service and characteristics for a single Yoto player.
22
+ */
23
+ export class YotoSpeakerAccessory {
24
+ /** @type {YotoPlatform} */ #platform
25
+ /** @type {PlatformAccessory<YotoAccessoryContext>} */ #accessory
26
+ /** @type {YotoDeviceModel} */ #deviceModel
27
+ /** @type {Logger} */ #log
28
+ /** @type {YotoDevice} */ #device
29
+ /** @type {Service | undefined} */ speakerService
30
+ /** @type {number} */ #lastNonZeroVolume = 50
31
+ /** @type {Set<Service>} */ #currentServices = new Set()
32
+
33
+ /**
34
+ * @param {Object} params
35
+ * @param {YotoPlatform} params.platform - Platform instance
36
+ * @param {PlatformAccessory<YotoAccessoryContext>} params.accessory - Platform accessory
37
+ * @param {YotoDeviceModel} params.deviceModel - Yoto device model with live state
38
+ */
39
+ constructor ({ platform, accessory, deviceModel }) {
40
+ this.#platform = platform
41
+ this.#accessory = accessory
42
+ this.#deviceModel = deviceModel
43
+ this.#log = platform.log
44
+
45
+ this.#device = accessory.context.device
46
+ this.#currentServices = new Set()
47
+ }
48
+
49
+ /**
50
+ * Setup accessory - create services and setup event listeners
51
+ * @returns {Promise<void>}
52
+ */
53
+ async setup () {
54
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Setting up speaker for ${this.#device.name}`)
55
+
56
+ this.#currentServices.clear()
57
+
58
+ this.setupAccessoryInformation()
59
+ this.setupSmartSpeakerService()
60
+
61
+ for (const service of this.#accessory.services) {
62
+ if (service.UUID !== this.#platform.Service.AccessoryInformation.UUID &&
63
+ !this.#currentServices.has(service)) {
64
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Removing stale speaker service: ${service.displayName || service.UUID}`)
65
+ this.#accessory.removeService(service)
66
+ }
67
+ }
68
+
69
+ this.setupEventListeners()
70
+
71
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `✓ ${this.#device.name} speaker ready`)
72
+ }
73
+
74
+ /**
75
+ * Setup AccessoryInformation service
76
+ */
77
+ setupAccessoryInformation () {
78
+ const { Service, Characteristic } = this.#platform
79
+ const service = this.#accessory.getService(Service.AccessoryInformation) ||
80
+ this.#accessory.addService(Service.AccessoryInformation)
81
+
82
+ const hardwareRevision = [
83
+ this.#device.generation,
84
+ this.#device.formFactor,
85
+ ].filter(Boolean).join(' ') || 'Unknown'
86
+
87
+ const model = this.#device.deviceFamily || this.#device.deviceType || DEFAULT_MODEL
88
+
89
+ service
90
+ .setCharacteristic(Characteristic.Manufacturer, DEFAULT_MANUFACTURER)
91
+ .setCharacteristic(Characteristic.Model, model)
92
+ .setCharacteristic(Characteristic.SerialNumber, this.#device.deviceId)
93
+ .setCharacteristic(Characteristic.HardwareRevision, hardwareRevision)
94
+
95
+ if (this.#deviceModel.status.firmwareVersion) {
96
+ service.setCharacteristic(
97
+ Characteristic.FirmwareRevision,
98
+ this.#deviceModel.status.firmwareVersion
99
+ )
100
+ }
101
+
102
+ this.#currentServices.add(service)
103
+ }
104
+
105
+ /**
106
+ * Setup SmartSpeaker service (PRIMARY)
107
+ */
108
+ setupSmartSpeakerService () {
109
+ const { Service, Characteristic } = this.#platform
110
+ const serviceName = sanitizeName(this.#accessory.displayName)
111
+
112
+ const service = this.#accessory.getService(Service.SmartSpeaker) ||
113
+ this.#accessory.addService(Service.SmartSpeaker, serviceName)
114
+
115
+ service.setPrimaryService(true)
116
+
117
+ syncServiceNames({ Characteristic, service, name: serviceName })
118
+
119
+ service
120
+ .getCharacteristic(Characteristic.CurrentMediaState)
121
+ .onGet(this.getCurrentMediaState.bind(this))
122
+
123
+ service
124
+ .getCharacteristic(Characteristic.TargetMediaState)
125
+ .onGet(this.getTargetMediaState.bind(this))
126
+ .onSet(this.setTargetMediaState.bind(this))
127
+
128
+ service
129
+ .getCharacteristic(Characteristic.Volume)
130
+ .setProps({
131
+ minValue: 0,
132
+ maxValue: 100,
133
+ minStep: 1,
134
+ })
135
+ .onGet(this.getVolume.bind(this))
136
+ .onSet(this.setVolume.bind(this))
137
+
138
+ service
139
+ .getCharacteristic(Characteristic.Mute)
140
+ .onGet(this.getMute.bind(this))
141
+ .onSet(this.setMute.bind(this))
142
+
143
+ const statusActiveUuid = Characteristic.StatusActive.UUID
144
+ const hasStatusActive = service.characteristics.some(c => c.UUID === statusActiveUuid)
145
+ const hasStatusActiveOptional = service.optionalCharacteristics.some(c => c.UUID === statusActiveUuid)
146
+ if (!hasStatusActive && !hasStatusActiveOptional) {
147
+ service.addOptionalCharacteristic(Characteristic.StatusActive)
148
+ }
149
+
150
+ service
151
+ .getCharacteristic(Characteristic.StatusActive)
152
+ .onGet(this.getStatusActive.bind(this))
153
+
154
+ this.speakerService = service
155
+ this.#currentServices.add(service)
156
+ }
157
+
158
+ /**
159
+ * Setup event listeners for device model updates
160
+ */
161
+ setupEventListeners () {
162
+ this.#deviceModel.on('statusUpdate', (status, _source, changedFields) => {
163
+ for (const field of changedFields) {
164
+ switch (field) {
165
+ case 'volume':
166
+ this.updateVolumeCharacteristic(status.volume)
167
+ break
168
+
169
+ case 'isOnline':
170
+ this.updateOnlineStatusCharacteristic(status.isOnline)
171
+ break
172
+
173
+ case 'firmwareVersion':
174
+ this.updateFirmwareVersionCharacteristic(status.firmwareVersion)
175
+ break
176
+
177
+ // Available but not mapped to SmartSpeaker characteristics
178
+ case 'batteryLevelPercentage':
179
+ case 'isCharging':
180
+ case 'maxVolume':
181
+ case 'temperatureCelsius':
182
+ case 'nightlightMode':
183
+ case 'dayMode':
184
+ case 'cardInsertionState':
185
+ case 'activeCardId':
186
+ case 'powerSource':
187
+ case 'wifiStrength':
188
+ case 'freeDiskSpaceBytes':
189
+ case 'totalDiskSpaceBytes':
190
+ case 'isAudioDeviceConnected':
191
+ case 'isBluetoothAudioConnected':
192
+ case 'ambientLightSensorReading':
193
+ case 'displayBrightness':
194
+ case 'timeFormat':
195
+ case 'uptime':
196
+ case 'updatedAt':
197
+ case 'source':
198
+ break
199
+
200
+ default: {
201
+ /** @type {never} */
202
+ const _exhaustive = field
203
+ this.#log.debug('Unhandled speaker status field:', _exhaustive)
204
+ break
205
+ }
206
+ }
207
+ }
208
+ })
209
+
210
+ this.#deviceModel.on('playbackUpdate', (playback, changedFields) => {
211
+ for (const field of changedFields) {
212
+ switch (field) {
213
+ case 'playbackStatus':
214
+ this.updateSmartSpeakerMediaStateCharacteristic(playback.playbackStatus)
215
+ break
216
+
217
+ case 'sleepTimerActive':
218
+ case 'position':
219
+ case 'trackLength':
220
+ case 'cardId':
221
+ case 'source':
222
+ case 'trackTitle':
223
+ case 'trackKey':
224
+ case 'chapterTitle':
225
+ case 'chapterKey':
226
+ case 'sleepTimerSeconds':
227
+ case 'streaming':
228
+ case 'updatedAt':
229
+ break
230
+
231
+ default: {
232
+ /** @type {never} */
233
+ const _exhaustive = field
234
+ this.#log.debug('Unhandled speaker playback field:', _exhaustive)
235
+ break
236
+ }
237
+ }
238
+ }
239
+ })
240
+
241
+ this.#deviceModel.on('online', ({ reason: _reason }) => {
242
+ this.updateOnlineStatusCharacteristic(true)
243
+ })
244
+
245
+ this.#deviceModel.on('offline', ({ reason: _reason }) => {
246
+ this.updateOnlineStatusCharacteristic(false)
247
+ })
248
+
249
+ this.#deviceModel.on('error', (error) => {
250
+ const details = error instanceof Error ? (error.stack || error.message) : String(error)
251
+ this.#log.error(`[${this.#device.name}] Speaker device error:`, details)
252
+ })
253
+ }
254
+
255
+ /**
256
+ * @param { "playing" | "paused" | "stopped" | "loading" | null} playbackStatus
257
+ * @returns {{ current: CharacteristicValue, target: CharacteristicValue }}
258
+ */
259
+ getMediaStateValues (playbackStatus) {
260
+ const { Characteristic } = this.#platform
261
+
262
+ if (playbackStatus === 'playing') {
263
+ return {
264
+ current: Characteristic.CurrentMediaState.PLAY,
265
+ target: Characteristic.TargetMediaState.PLAY,
266
+ }
267
+ }
268
+
269
+ if (playbackStatus === 'paused') {
270
+ return {
271
+ current: Characteristic.CurrentMediaState.PAUSE,
272
+ target: Characteristic.TargetMediaState.PAUSE,
273
+ }
274
+ }
275
+
276
+ return {
277
+ current: Characteristic.CurrentMediaState.STOP,
278
+ target: Characteristic.TargetMediaState.STOP,
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Get current media state from live playback state
284
+ * @returns {Promise<CharacteristicValue>}
285
+ */
286
+ async getCurrentMediaState () {
287
+ const playbackStatus = this.#deviceModel.playback.playbackStatus ?? null
288
+ return this.getMediaStateValues(playbackStatus).current
289
+ }
290
+
291
+ /**
292
+ * Get target media state (follows current state)
293
+ * @returns {Promise<CharacteristicValue>}
294
+ */
295
+ async getTargetMediaState () {
296
+ const playbackStatus = this.#deviceModel.playback.playbackStatus ?? null
297
+ return this.getMediaStateValues(playbackStatus).target
298
+ }
299
+
300
+ /**
301
+ * Set target media state (play/pause/stop)
302
+ * @param {CharacteristicValue} value - Target state
303
+ * @returns {Promise<void>}
304
+ */
305
+ async setTargetMediaState (value) {
306
+ const { Characteristic } = this.#platform
307
+ const targetValue = typeof value === 'number' ? value : Number(value)
308
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Set target media state:`, value)
309
+
310
+ try {
311
+ if (targetValue === Characteristic.TargetMediaState.PLAY) {
312
+ await this.#deviceModel.resumeCard()
313
+ return
314
+ }
315
+
316
+ if (targetValue === Characteristic.TargetMediaState.PAUSE) {
317
+ await this.#deviceModel.pauseCard()
318
+ return
319
+ }
320
+
321
+ if (targetValue === Characteristic.TargetMediaState.STOP) {
322
+ await this.#deviceModel.pauseCard()
323
+ }
324
+ } catch (error) {
325
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set media state:`, error)
326
+ throw new this.#platform.api.hap.HapStatusError(
327
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
328
+ )
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Get volume level as percentage (mapped from 0-16 steps)
334
+ * @returns {Promise<CharacteristicValue>}
335
+ */
336
+ async getVolume () {
337
+ const volumeSteps = this.#deviceModel.status.volume
338
+ const normalizedSteps = Number.isFinite(volumeSteps) ? volumeSteps : 0
339
+ const clampedSteps = Math.max(0, Math.min(normalizedSteps, 16))
340
+ return Math.round((clampedSteps / 16) * 100)
341
+ }
342
+
343
+ /**
344
+ * Set volume level as percentage (mapped to 0-16 steps)
345
+ * @param {CharacteristicValue} value - Volume level percent
346
+ * @returns {Promise<void>}
347
+ */
348
+ async setVolume (value) {
349
+ const deviceModel = this.#deviceModel
350
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Set speaker volume:`, value)
351
+
352
+ const requestedPercent = typeof value === 'number' ? value : Number(value)
353
+ if (!Number.isFinite(requestedPercent)) {
354
+ throw new this.#platform.api.hap.HapStatusError(
355
+ this.#platform.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST
356
+ )
357
+ }
358
+
359
+ const normalizedPercent = Math.max(0, Math.min(Math.round(requestedPercent), 100))
360
+ const requestedSteps = Math.round((normalizedPercent / 100) * 16)
361
+ const steps = Math.max(0, Math.min(Math.round(requestedSteps), 16))
362
+
363
+ if (steps > 0) {
364
+ this.#lastNonZeroVolume = Math.round((steps / 16) * 100)
365
+ }
366
+
367
+ try {
368
+ await deviceModel.setVolume(steps)
369
+ this.updateVolumeCharacteristic(steps)
370
+ } catch (error) {
371
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set speaker volume:`, error)
372
+ throw new this.#platform.api.hap.HapStatusError(
373
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
374
+ )
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Get mute state (derived from volume === 0)
380
+ * @returns {Promise<CharacteristicValue>}
381
+ */
382
+ async getMute () {
383
+ return this.#deviceModel.status.volume === 0
384
+ }
385
+
386
+ /**
387
+ * Set mute state
388
+ * @param {CharacteristicValue} value - Mute state
389
+ * @returns {Promise<void>}
390
+ */
391
+ async setMute (value) {
392
+ const isMuted = Boolean(value)
393
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Set speaker mute:`, isMuted)
394
+
395
+ if (isMuted) {
396
+ await this.setVolume(0)
397
+ return
398
+ }
399
+
400
+ await this.setVolume(this.#lastNonZeroVolume)
401
+ }
402
+
403
+ /**
404
+ * Get status active (online/offline)
405
+ * @returns {Promise<CharacteristicValue>}
406
+ */
407
+ async getStatusActive () {
408
+ return this.#deviceModel.status.isOnline
409
+ }
410
+
411
+ /**
412
+ * Update SmartSpeaker media state characteristics
413
+ * @param { "playing" | "paused" | "stopped" | "loading" | null} playbackStatus - Playback status
414
+ */
415
+ updateSmartSpeakerMediaStateCharacteristic (playbackStatus) {
416
+ if (!this.speakerService) return
417
+
418
+ const { Characteristic } = this.#platform
419
+ const { current, target } = this.getMediaStateValues(playbackStatus)
420
+
421
+ this.speakerService
422
+ .getCharacteristic(Characteristic.CurrentMediaState)
423
+ .updateValue(current)
424
+
425
+ this.speakerService
426
+ .getCharacteristic(Characteristic.TargetMediaState)
427
+ .updateValue(target)
428
+ }
429
+
430
+ /**
431
+ * Update volume and mute characteristics
432
+ * @param {number} volumeSteps - Volume level (0-16)
433
+ */
434
+ updateVolumeCharacteristic (volumeSteps) {
435
+ if (!this.speakerService) return
436
+
437
+ const normalizedVolume = Number.isFinite(volumeSteps) ? volumeSteps : 0
438
+ const clampedVolume = Math.max(0, Math.min(normalizedVolume, 16))
439
+ const percent = Math.round((clampedVolume / 16) * 100)
440
+ const isMuted = clampedVolume === 0
441
+
442
+ this.speakerService
443
+ .getCharacteristic(this.#platform.Characteristic.Volume)
444
+ .updateValue(percent)
445
+
446
+ this.speakerService
447
+ .getCharacteristic(this.#platform.Characteristic.Mute)
448
+ .updateValue(isMuted)
449
+ }
450
+
451
+ /**
452
+ * Update online status characteristic
453
+ * @param {boolean} isOnline - Online status
454
+ */
455
+ updateOnlineStatusCharacteristic (isOnline) {
456
+ if (!this.speakerService) return
457
+
458
+ this.speakerService
459
+ .getCharacteristic(this.#platform.Characteristic.StatusActive)
460
+ .updateValue(isOnline)
461
+ }
462
+
463
+ /**
464
+ * Update firmware version characteristic
465
+ * @param {string} firmwareVersion - Firmware version
466
+ */
467
+ updateFirmwareVersionCharacteristic (firmwareVersion) {
468
+ const { Service, Characteristic } = this.#platform
469
+ const infoService = this.#accessory.getService(Service.AccessoryInformation)
470
+ if (!infoService) return
471
+
472
+ infoService.setCharacteristic(
473
+ Characteristic.FirmwareRevision,
474
+ firmwareVersion
475
+ )
476
+ }
477
+
478
+ /**
479
+ * Stop accessory - cleanup event listeners
480
+ * @returns {Promise<void>}
481
+ */
482
+ async stop () {
483
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Stopping speaker for ${this.#device.name}`)
484
+
485
+ this.#deviceModel.removeAllListeners('statusUpdate')
486
+ this.#deviceModel.removeAllListeners('playbackUpdate')
487
+ this.#deviceModel.removeAllListeners('online')
488
+ this.#deviceModel.removeAllListeners('offline')
489
+ this.#deviceModel.removeAllListeners('error')
490
+ }
491
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-yoto",
3
3
  "description": "Control your Yoto players through Apple HomeKit with real-time MQTT updates",
4
- "version": "0.0.39",
4
+ "version": "0.0.41",
5
5
  "author": "Bret Comnes <bcomnes@gmail.com> (https://bret.io)",
6
6
  "bugs": {
7
7
  "url": "https://github.com/bcomnes/homebridge-yoto/issues"
@@ -9,7 +9,7 @@
9
9
  "dependencies": {
10
10
  "@homebridge/plugin-ui-utils": "^2.1.2",
11
11
  "color-convert": "3.1.3",
12
- "yoto-nodejs-client": "^0.0.7"
12
+ "yoto-nodejs-client": "^0.0.8"
13
13
  },
14
14
  "devDependencies": {
15
15
  "@types/node": "^25.0.0",