homebridge-yoto 0.0.38 → 0.0.40

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,490 @@
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
+ this.#log.error(`[${this.#device.name}] Speaker device error:`, error.message)
251
+ })
252
+ }
253
+
254
+ /**
255
+ * @param { "playing" | "paused" | "stopped" | "loading" | null} playbackStatus
256
+ * @returns {{ current: CharacteristicValue, target: CharacteristicValue }}
257
+ */
258
+ getMediaStateValues (playbackStatus) {
259
+ const { Characteristic } = this.#platform
260
+
261
+ if (playbackStatus === 'playing') {
262
+ return {
263
+ current: Characteristic.CurrentMediaState.PLAY,
264
+ target: Characteristic.TargetMediaState.PLAY,
265
+ }
266
+ }
267
+
268
+ if (playbackStatus === 'paused') {
269
+ return {
270
+ current: Characteristic.CurrentMediaState.PAUSE,
271
+ target: Characteristic.TargetMediaState.PAUSE,
272
+ }
273
+ }
274
+
275
+ return {
276
+ current: Characteristic.CurrentMediaState.STOP,
277
+ target: Characteristic.TargetMediaState.STOP,
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Get current media state from live playback state
283
+ * @returns {Promise<CharacteristicValue>}
284
+ */
285
+ async getCurrentMediaState () {
286
+ const playbackStatus = this.#deviceModel.playback.playbackStatus ?? null
287
+ return this.getMediaStateValues(playbackStatus).current
288
+ }
289
+
290
+ /**
291
+ * Get target media state (follows current state)
292
+ * @returns {Promise<CharacteristicValue>}
293
+ */
294
+ async getTargetMediaState () {
295
+ const playbackStatus = this.#deviceModel.playback.playbackStatus ?? null
296
+ return this.getMediaStateValues(playbackStatus).target
297
+ }
298
+
299
+ /**
300
+ * Set target media state (play/pause/stop)
301
+ * @param {CharacteristicValue} value - Target state
302
+ * @returns {Promise<void>}
303
+ */
304
+ async setTargetMediaState (value) {
305
+ const { Characteristic } = this.#platform
306
+ const targetValue = typeof value === 'number' ? value : Number(value)
307
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Set target media state:`, value)
308
+
309
+ try {
310
+ if (targetValue === Characteristic.TargetMediaState.PLAY) {
311
+ await this.#deviceModel.resumeCard()
312
+ return
313
+ }
314
+
315
+ if (targetValue === Characteristic.TargetMediaState.PAUSE) {
316
+ await this.#deviceModel.pauseCard()
317
+ return
318
+ }
319
+
320
+ if (targetValue === Characteristic.TargetMediaState.STOP) {
321
+ await this.#deviceModel.pauseCard()
322
+ }
323
+ } catch (error) {
324
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set media state:`, error)
325
+ throw new this.#platform.api.hap.HapStatusError(
326
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
327
+ )
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Get volume level as percentage (mapped from 0-16 steps)
333
+ * @returns {Promise<CharacteristicValue>}
334
+ */
335
+ async getVolume () {
336
+ const volumeSteps = this.#deviceModel.status.volume
337
+ const normalizedSteps = Number.isFinite(volumeSteps) ? volumeSteps : 0
338
+ const clampedSteps = Math.max(0, Math.min(normalizedSteps, 16))
339
+ return Math.round((clampedSteps / 16) * 100)
340
+ }
341
+
342
+ /**
343
+ * Set volume level as percentage (mapped to 0-16 steps)
344
+ * @param {CharacteristicValue} value - Volume level percent
345
+ * @returns {Promise<void>}
346
+ */
347
+ async setVolume (value) {
348
+ const deviceModel = this.#deviceModel
349
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Set speaker volume:`, value)
350
+
351
+ const requestedPercent = typeof value === 'number' ? value : Number(value)
352
+ if (!Number.isFinite(requestedPercent)) {
353
+ throw new this.#platform.api.hap.HapStatusError(
354
+ this.#platform.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST
355
+ )
356
+ }
357
+
358
+ const normalizedPercent = Math.max(0, Math.min(Math.round(requestedPercent), 100))
359
+ const requestedSteps = Math.round((normalizedPercent / 100) * 16)
360
+ const steps = Math.max(0, Math.min(Math.round(requestedSteps), 16))
361
+
362
+ if (steps > 0) {
363
+ this.#lastNonZeroVolume = Math.round((steps / 16) * 100)
364
+ }
365
+
366
+ try {
367
+ await deviceModel.setVolume(steps)
368
+ this.updateVolumeCharacteristic(steps)
369
+ } catch (error) {
370
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set speaker volume:`, error)
371
+ throw new this.#platform.api.hap.HapStatusError(
372
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
373
+ )
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Get mute state (derived from volume === 0)
379
+ * @returns {Promise<CharacteristicValue>}
380
+ */
381
+ async getMute () {
382
+ return this.#deviceModel.status.volume === 0
383
+ }
384
+
385
+ /**
386
+ * Set mute state
387
+ * @param {CharacteristicValue} value - Mute state
388
+ * @returns {Promise<void>}
389
+ */
390
+ async setMute (value) {
391
+ const isMuted = Boolean(value)
392
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Set speaker mute:`, isMuted)
393
+
394
+ if (isMuted) {
395
+ await this.setVolume(0)
396
+ return
397
+ }
398
+
399
+ await this.setVolume(this.#lastNonZeroVolume)
400
+ }
401
+
402
+ /**
403
+ * Get status active (online/offline)
404
+ * @returns {Promise<CharacteristicValue>}
405
+ */
406
+ async getStatusActive () {
407
+ return this.#deviceModel.status.isOnline
408
+ }
409
+
410
+ /**
411
+ * Update SmartSpeaker media state characteristics
412
+ * @param { "playing" | "paused" | "stopped" | "loading" | null} playbackStatus - Playback status
413
+ */
414
+ updateSmartSpeakerMediaStateCharacteristic (playbackStatus) {
415
+ if (!this.speakerService) return
416
+
417
+ const { Characteristic } = this.#platform
418
+ const { current, target } = this.getMediaStateValues(playbackStatus)
419
+
420
+ this.speakerService
421
+ .getCharacteristic(Characteristic.CurrentMediaState)
422
+ .updateValue(current)
423
+
424
+ this.speakerService
425
+ .getCharacteristic(Characteristic.TargetMediaState)
426
+ .updateValue(target)
427
+ }
428
+
429
+ /**
430
+ * Update volume and mute characteristics
431
+ * @param {number} volumeSteps - Volume level (0-16)
432
+ */
433
+ updateVolumeCharacteristic (volumeSteps) {
434
+ if (!this.speakerService) return
435
+
436
+ const normalizedVolume = Number.isFinite(volumeSteps) ? volumeSteps : 0
437
+ const clampedVolume = Math.max(0, Math.min(normalizedVolume, 16))
438
+ const percent = Math.round((clampedVolume / 16) * 100)
439
+ const isMuted = clampedVolume === 0
440
+
441
+ this.speakerService
442
+ .getCharacteristic(this.#platform.Characteristic.Volume)
443
+ .updateValue(percent)
444
+
445
+ this.speakerService
446
+ .getCharacteristic(this.#platform.Characteristic.Mute)
447
+ .updateValue(isMuted)
448
+ }
449
+
450
+ /**
451
+ * Update online status characteristic
452
+ * @param {boolean} isOnline - Online status
453
+ */
454
+ updateOnlineStatusCharacteristic (isOnline) {
455
+ if (!this.speakerService) return
456
+
457
+ this.speakerService
458
+ .getCharacteristic(this.#platform.Characteristic.StatusActive)
459
+ .updateValue(isOnline)
460
+ }
461
+
462
+ /**
463
+ * Update firmware version characteristic
464
+ * @param {string} firmwareVersion - Firmware version
465
+ */
466
+ updateFirmwareVersionCharacteristic (firmwareVersion) {
467
+ const { Service, Characteristic } = this.#platform
468
+ const infoService = this.#accessory.getService(Service.AccessoryInformation)
469
+ if (!infoService) return
470
+
471
+ infoService.setCharacteristic(
472
+ Characteristic.FirmwareRevision,
473
+ firmwareVersion
474
+ )
475
+ }
476
+
477
+ /**
478
+ * Stop accessory - cleanup event listeners
479
+ * @returns {Promise<void>}
480
+ */
481
+ async stop () {
482
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Stopping speaker for ${this.#device.name}`)
483
+
484
+ this.#deviceModel.removeAllListeners('statusUpdate')
485
+ this.#deviceModel.removeAllListeners('playbackUpdate')
486
+ this.#deviceModel.removeAllListeners('online')
487
+ this.#deviceModel.removeAllListeners('offline')
488
+ this.#deviceModel.removeAllListeners('error')
489
+ }
490
+ }
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.38",
4
+ "version": "0.0.40",
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",