homebridge-yoto 0.0.28 → 0.0.32

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.
@@ -1,1724 +0,0 @@
1
- /**
2
- * @fileoverview Yoto Player Accessory implementation - handles HomeKit services for a single player
3
- */
4
-
5
- /** @import { PlatformAccessory, CharacteristicValue } from 'homebridge' */
6
- /** @import { YotoPlatform } from './platform.js' */
7
- /** @import { YotoAccessoryContext, YotoDeviceStatus, YotoPlaybackEvents } from './types.js' */
8
-
9
- import { YotoMqtt } from './yotoMqtt.js'
10
- import {
11
- DEFAULT_MANUFACTURER,
12
- DEFAULT_MODEL,
13
- LOW_BATTERY_THRESHOLD,
14
- PLAYBACK_STATUS,
15
- CARD_INSERTION_STATE,
16
- DEFAULT_CONFIG,
17
- LOG_PREFIX
18
- } from './constants.js'
19
-
20
- /**
21
- * Yoto Player Accessory Handler
22
- */
23
- export class YotoPlayerAccessory {
24
- /**
25
- * @param {YotoPlatform} platform - Platform instance
26
- * @param {PlatformAccessory<YotoAccessoryContext>} accessory - Platform accessory
27
- */
28
- constructor (platform, accessory) {
29
- this.platform = platform
30
- this.accessory = accessory
31
- this.log = platform.log
32
-
33
- // Get device info from context
34
- this.device = accessory.context.device
35
-
36
- // Cache for current state
37
- this.currentStatus = accessory.context.lastStatus || null
38
- this.currentEvents = accessory.context.lastEvents || null
39
- this.lastUpdateTime = Date.now()
40
-
41
- // Cache for device config
42
- this.deviceConfig = null
43
-
44
- // Create dedicated MQTT client for this device
45
- this.mqtt = new YotoMqtt(this.log)
46
-
47
- // Track online status polling
48
- this.statusPollInterval = null
49
- this.mqttConnected = false
50
-
51
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Initializing accessory`)
52
-
53
- // Set up services
54
- this.setupAccessoryInformation()
55
- this.setupSmartSpeakerService()
56
-
57
- // Optional services based on config
58
- if (this.platform.config.exposeBattery !== false) {
59
- this.setupBatteryService()
60
- }
61
-
62
- if (this.platform.config.exposeTemperature) {
63
- this.setupTemperatureService()
64
- }
65
-
66
- if (this.platform.config.exposeConnectionStatus) {
67
- this.setupConnectionStatusService()
68
- }
69
-
70
- if (this.platform.config.exposeCardDetection) {
71
- this.setupCardDetectionService()
72
- }
73
-
74
- // Display brightness control (optional)
75
- if (this.platform.config.exposeDisplayBrightness !== false) {
76
- this.setupDisplayBrightnessService()
77
- }
78
-
79
- // Advanced control switches (optional)
80
- if (this.platform.config.exposeAdvancedControls) {
81
- this.setupAdvancedControlSwitches()
82
- }
83
-
84
- // Sleep timer control (optional)
85
- if (this.platform.config.exposeSleepTimer) {
86
- this.setupSleepTimerService()
87
- }
88
-
89
- // Volume limits control (optional)
90
- if (this.platform.config.exposeVolumeLimits) {
91
- this.setupVolumeLimitsServices()
92
- }
93
-
94
- // Ambient light control (optional)
95
- if (this.platform.config.exposeAmbientLight) {
96
- this.setupAmbientLightService()
97
- }
98
-
99
- // Active content tracking (optional)
100
- if (this.platform.config.exposeActiveContent !== false) {
101
- /** @type {string | null} */
102
- this.activeContentCardId = null
103
- /** @type {import('./types.js').YotoCardContent | null} */
104
- this.activeContentInfo = null
105
- }
106
-
107
- // MQTT connection will be initiated by platform after construction
108
- }
109
-
110
- /**
111
- * Initialize the accessory - connect MQTT and subscribe to updates
112
- * Called by platform after construction
113
- * @returns {Promise<void>}
114
- */
115
- async initialize () {
116
- // Fetch device config for cached access
117
- try {
118
- this.deviceConfig = await this.platform.yotoApi.getDeviceConfig(this.device.deviceId)
119
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Device config loaded`)
120
- } catch (error) {
121
- this.log.warn(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Failed to load device config:`, error)
122
- // Continue without config - getters will return defaults
123
- }
124
-
125
- await this.connectMqtt()
126
- // Status polling is handled at platform level
127
- }
128
-
129
- /**
130
- * Handle online status change from platform polling
131
- * @param {boolean} isNowOnline - Current online status
132
- * @param {boolean} wasOnline - Previous online status
133
- * @returns {Promise<void>}
134
- */
135
- async handleOnlineStatusChange (isNowOnline, wasOnline) {
136
- // Update device reference from accessory context (platform already updated it)
137
- this.device = this.accessory.context.device
138
-
139
- // Handle state changes
140
- if (!wasOnline && isNowOnline) {
141
- this.log.info(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Device came online, connecting MQTT...`)
142
- await this.connectMqtt()
143
- } else if (wasOnline && !isNowOnline) {
144
- this.log.warn(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Device went offline`)
145
- await this.disconnectMqtt()
146
- }
147
- }
148
-
149
- /**
150
- * Disconnect MQTT for this device
151
- * @returns {Promise<void>}
152
- */
153
- async disconnectMqtt () {
154
- if (this.mqtt && this.mqttConnected) {
155
- try {
156
- await this.mqtt.disconnect()
157
- this.mqttConnected = false
158
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] MQTT disconnected`)
159
- } catch (error) {
160
- this.log.error(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Failed to disconnect MQTT:`, error)
161
- }
162
- }
163
- }
164
-
165
- /**
166
- * Set up accessory information service
167
- */
168
- setupAccessoryInformation () {
169
- const infoService = this.accessory.getService(this.platform.Service.AccessoryInformation)
170
- if (infoService) {
171
- infoService
172
- .setCharacteristic(this.platform.Characteristic.Manufacturer, DEFAULT_MANUFACTURER)
173
- .setCharacteristic(this.platform.Characteristic.Model, this.device.deviceType || DEFAULT_MODEL)
174
- .setCharacteristic(this.platform.Characteristic.SerialNumber, this.device.deviceId)
175
- .setCharacteristic(this.platform.Characteristic.FirmwareRevision, this.device.releaseChannel || '1.0.0')
176
- .setCharacteristic(this.platform.Characteristic.Name, this.accessory.displayName)
177
- }
178
- }
179
-
180
- /**
181
- * Set up smart speaker service for media control
182
- */
183
- setupSmartSpeakerService () {
184
- // Get or create the SmartSpeaker service
185
- this.speakerService =
186
- this.accessory.getService(this.platform.Service.SmartSpeaker) ||
187
- this.accessory.addService(this.platform.Service.SmartSpeaker)
188
-
189
- this.speakerService.setCharacteristic(this.platform.Characteristic.Name, this.accessory.displayName)
190
-
191
- // Current Media State (read-only)
192
- this.speakerService
193
- .getCharacteristic(this.platform.Characteristic.CurrentMediaState)
194
- .onGet(this.getCurrentMediaState.bind(this))
195
-
196
- // Target Media State (control playback)
197
- this.speakerService
198
- .getCharacteristic(this.platform.Characteristic.TargetMediaState)
199
- .onGet(this.getTargetMediaState.bind(this))
200
- .onSet(this.setTargetMediaState.bind(this))
201
-
202
- // Volume
203
- this.speakerService
204
- .getCharacteristic(this.platform.Characteristic.Volume)
205
- .onGet(this.getVolume.bind(this))
206
- .onSet(this.setVolume.bind(this))
207
-
208
- // Mute
209
- this.speakerService
210
- .getCharacteristic(this.platform.Characteristic.Mute)
211
- .onGet(this.getMute.bind(this))
212
- .onSet(this.setMute.bind(this))
213
- }
214
-
215
- /**
216
- * Set up battery service
217
- */
218
- setupBatteryService () {
219
- this.batteryService =
220
- this.accessory.getService(this.platform.Service.Battery) ||
221
- this.accessory.addService(this.platform.Service.Battery)
222
-
223
- this.batteryService.setCharacteristic(this.platform.Characteristic.Name, `${this.accessory.displayName} Battery`)
224
-
225
- // Battery Level
226
- this.batteryService
227
- .getCharacteristic(this.platform.Characteristic.BatteryLevel)
228
- .onGet(this.getBatteryLevel.bind(this))
229
-
230
- // Charging State
231
- this.batteryService
232
- .getCharacteristic(this.platform.Characteristic.ChargingState)
233
- .onGet(this.getChargingState.bind(this))
234
-
235
- // Status Low Battery
236
- this.batteryService
237
- .getCharacteristic(this.platform.Characteristic.StatusLowBattery)
238
- .onGet(this.getStatusLowBattery.bind(this))
239
- }
240
-
241
- /**
242
- * Set up temperature sensor service
243
- */
244
- setupTemperatureService () {
245
- this.temperatureService =
246
- this.accessory.getService(this.platform.Service.TemperatureSensor) ||
247
- this.accessory.addService(this.platform.Service.TemperatureSensor)
248
-
249
- this.temperatureService.setCharacteristic(this.platform.Characteristic.Name, `${this.accessory.displayName} Temperature`)
250
-
251
- this.temperatureService
252
- .getCharacteristic(this.platform.Characteristic.CurrentTemperature)
253
- .onGet(this.getCurrentTemperature.bind(this))
254
- }
255
-
256
- /**
257
- * Set up connection status sensor (occupancy sensor)
258
- */
259
- setupConnectionStatusService () {
260
- this.connectionService =
261
- this.accessory.getService(this.platform.Service.OccupancySensor) ||
262
- this.accessory.addService(this.platform.Service.OccupancySensor)
263
-
264
- this.connectionService.setCharacteristic(this.platform.Characteristic.Name, `${this.accessory.displayName} Connection`)
265
-
266
- this.connectionService
267
- .getCharacteristic(this.platform.Characteristic.OccupancyDetected)
268
- .onGet(this.getOccupancyDetected.bind(this))
269
-
270
- this.connectionService
271
- .getCharacteristic(this.platform.Characteristic.StatusActive)
272
- .onGet(this.getStatusActive.bind(this))
273
- }
274
-
275
- /**
276
- * Set up card detection sensor (contact sensor)
277
- */
278
- setupCardDetectionService () {
279
- this.cardDetectionService =
280
- this.accessory.getService(this.platform.Service.ContactSensor) ||
281
- this.accessory.addService(this.platform.Service.ContactSensor)
282
-
283
- this.cardDetectionService.setCharacteristic(this.platform.Characteristic.Name, `${this.accessory.displayName} Card`)
284
-
285
- this.cardDetectionService
286
- .getCharacteristic(this.platform.Characteristic.ContactSensorState)
287
- .onGet(this.getContactSensorState.bind(this))
288
- }
289
-
290
- /**
291
- * Set up display brightness control (lightbulb service)
292
- */
293
- setupDisplayBrightnessService () {
294
- this.displayService =
295
- this.accessory.getService(this.platform.Service.Lightbulb) ||
296
- this.accessory.addService(this.platform.Service.Lightbulb)
297
-
298
- this.displayService.setCharacteristic(this.platform.Characteristic.Name, `${this.accessory.displayName} Display`)
299
-
300
- // On/Off state
301
- this.displayService
302
- .getCharacteristic(this.platform.Characteristic.On)
303
- .onGet(this.getDisplayOn.bind(this))
304
- .onSet(this.setDisplayOn.bind(this))
305
-
306
- // Brightness
307
- this.displayService
308
- .getCharacteristic(this.platform.Characteristic.Brightness)
309
- .onGet(this.getDisplayBrightness.bind(this))
310
- .onSet(this.setDisplayBrightness.bind(this))
311
- }
312
-
313
- /**
314
- * Set up advanced control switches
315
- */
316
- setupAdvancedControlSwitches () {
317
- // Bluetooth enabled switch
318
- this.bluetoothSwitch =
319
- this.accessory.getServiceById(this.platform.Service.Switch, 'bluetooth') ||
320
- this.accessory.addService(this.platform.Service.Switch, `${this.accessory.displayName} Bluetooth`, 'bluetooth')
321
-
322
- this.bluetoothSwitch.setCharacteristic(this.platform.Characteristic.Name, `${this.accessory.displayName} Bluetooth`)
323
-
324
- this.bluetoothSwitch
325
- .getCharacteristic(this.platform.Characteristic.On)
326
- .onGet(this.getBluetoothEnabled.bind(this))
327
- .onSet(this.setBluetoothEnabled.bind(this))
328
-
329
- // Repeat all switch
330
- this.repeatSwitch =
331
- this.accessory.getServiceById(this.platform.Service.Switch, 'repeat') ||
332
- this.accessory.addService(this.platform.Service.Switch, `${this.accessory.displayName} Repeat`, 'repeat')
333
-
334
- this.repeatSwitch.setCharacteristic(this.platform.Characteristic.Name, `${this.accessory.displayName} Repeat`)
335
-
336
- this.repeatSwitch
337
- .getCharacteristic(this.platform.Characteristic.On)
338
- .onGet(this.getRepeatAll.bind(this))
339
- .onSet(this.setRepeatAll.bind(this))
340
-
341
- // Bluetooth headphones switch
342
- this.btHeadphonesSwitch =
343
- this.accessory.getServiceById(this.platform.Service.Switch, 'bt-headphones') ||
344
- this.accessory.addService(this.platform.Service.Switch, `${this.accessory.displayName} BT Headphones`, 'bt-headphones')
345
-
346
- this.btHeadphonesSwitch.setCharacteristic(this.platform.Characteristic.Name, `${this.accessory.displayName} BT Headphones`)
347
-
348
- this.btHeadphonesSwitch
349
- .getCharacteristic(this.platform.Characteristic.On)
350
- .onGet(this.getBtHeadphonesEnabled.bind(this))
351
- .onSet(this.setBtHeadphonesEnabled.bind(this))
352
- }
353
-
354
- /**
355
- * Set up sleep timer control (fan service)
356
- */
357
- setupSleepTimerService () {
358
- this.sleepTimerService =
359
- this.accessory.getService(this.platform.Service.Fanv2) ||
360
- this.accessory.addService(this.platform.Service.Fanv2)
361
-
362
- this.sleepTimerService.setCharacteristic(this.platform.Characteristic.Name, `${this.accessory.displayName} Sleep Timer`)
363
-
364
- // Active state (timer on/off)
365
- this.sleepTimerService
366
- .getCharacteristic(this.platform.Characteristic.Active)
367
- .onGet(this.getSleepTimerActive.bind(this))
368
- .onSet(this.setSleepTimerActive.bind(this))
369
-
370
- // Rotation speed represents minutes (0-100 mapped to 0-120 minutes)
371
- this.sleepTimerService
372
- .getCharacteristic(this.platform.Characteristic.RotationSpeed)
373
- .setProps({ minValue: 0, maxValue: 100, minStep: 1 })
374
- .onGet(this.getSleepTimerMinutes.bind(this))
375
- .onSet(this.setSleepTimerMinutes.bind(this))
376
- }
377
-
378
- /**
379
- * Set up volume limit controls (lightbulb services)
380
- */
381
- setupVolumeLimitsServices () {
382
- // Day volume limit
383
- this.dayVolumeLimitService =
384
- this.accessory.getServiceById(this.platform.Service.Lightbulb, 'day-volume-limit') ||
385
- this.accessory.addService(this.platform.Service.Lightbulb, `${this.accessory.displayName} Day Volume Limit`, 'day-volume-limit')
386
-
387
- this.dayVolumeLimitService.setCharacteristic(this.platform.Characteristic.Name, `${this.accessory.displayName} Day Volume Limit`)
388
-
389
- this.dayVolumeLimitService
390
- .getCharacteristic(this.platform.Characteristic.On)
391
- .onGet(this.getDayVolumeLimitEnabled.bind(this))
392
- .onSet(this.setDayVolumeLimitEnabled.bind(this))
393
-
394
- this.dayVolumeLimitService
395
- .getCharacteristic(this.platform.Characteristic.Brightness)
396
- .setProps({ minValue: 0, maxValue: 100, minStep: 1 })
397
- .onGet(this.getDayVolumeLimit.bind(this))
398
- .onSet(this.setDayVolumeLimit.bind(this))
399
-
400
- // Night volume limit
401
- this.nightVolumeLimitService =
402
- this.accessory.getServiceById(this.platform.Service.Lightbulb, 'night-volume-limit') ||
403
- this.accessory.addService(this.platform.Service.Lightbulb, `${this.accessory.displayName} Night Volume Limit`, 'night-volume-limit')
404
-
405
- this.nightVolumeLimitService.setCharacteristic(this.platform.Characteristic.Name, `${this.accessory.displayName} Night Volume Limit`)
406
-
407
- this.nightVolumeLimitService
408
- .getCharacteristic(this.platform.Characteristic.On)
409
- .onGet(this.getNightVolumeLimitEnabled.bind(this))
410
- .onSet(this.setNightVolumeLimitEnabled.bind(this))
411
-
412
- this.nightVolumeLimitService
413
- .getCharacteristic(this.platform.Characteristic.Brightness)
414
- .setProps({ minValue: 0, maxValue: 100, minStep: 1 })
415
- .onGet(this.getNightVolumeLimit.bind(this))
416
- .onSet(this.setNightVolumeLimit.bind(this))
417
- }
418
-
419
- /**
420
- * Set up ambient light control (lightbulb service with color)
421
- */
422
- setupAmbientLightService () {
423
- this.ambientLightService =
424
- this.accessory.getServiceById(this.platform.Service.Lightbulb, 'ambient-light') ||
425
- this.accessory.addService(this.platform.Service.Lightbulb, `${this.accessory.displayName} Ambient Light`, 'ambient-light')
426
-
427
- this.ambientLightService.setCharacteristic(this.platform.Characteristic.Name, `${this.accessory.displayName} Ambient Light`)
428
-
429
- // On/Off state
430
- this.ambientLightService
431
- .getCharacteristic(this.platform.Characteristic.On)
432
- .onGet(this.getAmbientLightOn.bind(this))
433
- .onSet(this.setAmbientLightOn.bind(this))
434
-
435
- // Hue
436
- this.ambientLightService
437
- .getCharacteristic(this.platform.Characteristic.Hue)
438
- .onGet(this.getAmbientLightHue.bind(this))
439
- .onSet(this.setAmbientLightHue.bind(this))
440
-
441
- // Saturation
442
- this.ambientLightService
443
- .getCharacteristic(this.platform.Characteristic.Saturation)
444
- .onGet(this.getAmbientLightSaturation.bind(this))
445
- .onSet(this.setAmbientLightSaturation.bind(this))
446
-
447
- // Brightness
448
- this.ambientLightService
449
- .getCharacteristic(this.platform.Characteristic.Brightness)
450
- .onGet(this.getAmbientLightBrightness.bind(this))
451
- .onSet(this.setAmbientLightBrightness.bind(this))
452
- }
453
-
454
- /**
455
- * Cleanup and destroy the accessory
456
- */
457
- async destroy () {
458
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Destroying accessory`)
459
-
460
- // Disconnect MQTT
461
- await this.disconnectMqtt()
462
- }
463
-
464
- /**
465
- * Connect MQTT for this device
466
- */
467
- async connectMqtt () {
468
- try {
469
- // Check if device is online first
470
- if (!this.device.online) {
471
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Device is offline, skipping MQTT connection`)
472
- return
473
- }
474
-
475
- // Ensure we have an access token
476
- if (!this.platform.config.accessToken) {
477
- this.log.warn(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] No access token available for MQTT connection`)
478
- return
479
- }
480
-
481
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Connecting to MQTT (device online: ${this.device.online})`)
482
-
483
- // Connect MQTT with device ID and access token
484
- await this.mqtt.connect(
485
- this.platform.config.accessToken,
486
- this.device.deviceId
487
- )
488
-
489
- // Subscribe to device topics
490
- await this.mqtt.subscribeToDevice(this.device.deviceId, {
491
- onStatus: this.handleStatusUpdate.bind(this),
492
- onEvents: this.handleEventsUpdate.bind(this),
493
- onResponse: this.handleCommandResponse.bind(this)
494
- })
495
-
496
- this.mqttConnected = true
497
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] MQTT connected and subscribed`)
498
-
499
- // Set up MQTT event listeners
500
- this.mqtt.on('disconnected', () => {
501
- this.mqttConnected = false
502
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] MQTT disconnected`)
503
- })
504
-
505
- this.mqtt.on('offline', () => {
506
- this.mqttConnected = false
507
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] MQTT offline`)
508
- })
509
-
510
- this.mqtt.on('connected', () => {
511
- this.mqttConnected = true
512
- this.log.info(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] MQTT reconnected`)
513
- })
514
- } catch (error) {
515
- this.mqttConnected = false
516
- this.log.error(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Failed to connect MQTT:`, error)
517
- }
518
- }
519
-
520
- /**
521
- * Handle status update from MQTT
522
- * @param {YotoDeviceStatus | {status: YotoDeviceStatus}} statusMessage - Status data or wrapped status
523
- */
524
- handleStatusUpdate (statusMessage) {
525
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Raw MQTT status message:`, JSON.stringify(statusMessage, null, 2))
526
-
527
- // Unwrap status if it's nested under a 'status' property
528
- const status = /** @type {YotoDeviceStatus} */ ('status' in statusMessage ? statusMessage.status : statusMessage)
529
-
530
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Unwrapped status - batteryLevel:`, status.batteryLevel)
531
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Unwrapped status - userVolume:`, status.userVolume)
532
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Unwrapped status - volume:`, status.volume)
533
-
534
- this.currentStatus = status
535
- this.lastUpdateTime = Date.now()
536
- this.accessory.context.lastStatus = status
537
- this.accessory.context.lastUpdate = this.lastUpdateTime
538
-
539
- this.updateCharacteristics()
540
- }
541
-
542
- /**
543
- * Handle playback events update from MQTT
544
- * @param {YotoPlaybackEvents | {events: YotoPlaybackEvents}} eventsMessage - Playback events or wrapped events
545
- */
546
- handleEventsUpdate (eventsMessage) {
547
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Raw MQTT events message:`, JSON.stringify(eventsMessage, null, 2))
548
-
549
- // Unwrap events if it's nested under an 'events' property
550
- const events = /** @type {YotoPlaybackEvents} */ ('events' in eventsMessage ? eventsMessage.events : eventsMessage)
551
-
552
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Unwrapped events - cardId:`, events.cardId)
553
- this.currentEvents = events
554
- this.lastUpdateTime = Date.now()
555
- this.accessory.context.lastEvents = events
556
-
557
- // Track active content changes with event data
558
- if (this.platform.config.exposeActiveContent !== false && events.cardId) {
559
- this.handleActiveContentChange(events.cardId, events)
560
- }
561
-
562
- // Update playback-related characteristics
563
- this.updatePlaybackCharacteristics()
564
- }
565
-
566
- /**
567
- * Handle command response from MQTT
568
- * @param {import('./types.js').MqttCommandResponse} response - Command response
569
- */
570
- handleCommandResponse (response) {
571
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Command response:`, response)
572
- }
573
-
574
- /**
575
- * Update all characteristics with current state
576
- */
577
- updateCharacteristics () {
578
- if (!this.currentStatus) {
579
- return
580
- }
581
-
582
- // Update volume from events, not status
583
- if (this.speakerService && this.currentEvents?.volume !== undefined) {
584
- const volume = Number(this.currentEvents.volume) || 0
585
- this.speakerService.updateCharacteristic(
586
- this.platform.Characteristic.Volume,
587
- volume
588
- )
589
- }
590
-
591
- // Update battery
592
- if (this.batteryService && this.currentStatus.batteryLevel !== undefined) {
593
- this.batteryService.updateCharacteristic(
594
- this.platform.Characteristic.BatteryLevel,
595
- this.currentStatus.batteryLevel
596
- )
597
-
598
- this.batteryService.updateCharacteristic(
599
- this.platform.Characteristic.ChargingState,
600
- this.currentStatus.charging
601
- ? this.platform.Characteristic.ChargingState.CHARGING
602
- : this.platform.Characteristic.ChargingState.NOT_CHARGING
603
- )
604
-
605
- this.batteryService.updateCharacteristic(
606
- this.platform.Characteristic.StatusLowBattery,
607
- this.currentStatus.batteryLevel < LOW_BATTERY_THRESHOLD
608
- ? this.platform.Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW
609
- : this.platform.Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL
610
- )
611
- }
612
-
613
- // Update temperature
614
- if (this.temperatureService && this.currentStatus.temp) {
615
- // Parse temp string format "x:temp:y" to get middle value
616
- const tempParts = this.currentStatus.temp.split(':')
617
- if (tempParts.length >= 2) {
618
- const temp = Number(tempParts[1])
619
- if (!isNaN(temp)) {
620
- // Clamp to HomeKit's valid range: 0-100°C
621
- const clampedTemp = Math.max(0, Math.min(100, temp))
622
- this.temperatureService.updateCharacteristic(
623
- this.platform.Characteristic.CurrentTemperature,
624
- clampedTemp
625
- )
626
- }
627
- }
628
-
629
- // Update display brightness
630
- if (this.displayService && this.currentStatus.dnowBrightness !== undefined) {
631
- const isOn = this.currentStatus.dnowBrightness > 0
632
- this.displayService.updateCharacteristic(
633
- this.platform.Characteristic.On,
634
- isOn
635
- )
636
-
637
- // Map brightness (0-100) from device brightness value
638
- const brightness = Math.min(100, Math.max(0, this.currentStatus.dnowBrightness))
639
- this.displayService.updateCharacteristic(
640
- this.platform.Characteristic.Brightness,
641
- brightness
642
- )
643
- }
644
-
645
- // Update advanced control switches
646
- if (this.bluetoothSwitch && this.currentStatus.bluetoothHp !== undefined) {
647
- const bluetoothEnabled = this.currentStatus.bluetoothHp
648
- this.bluetoothSwitch.updateCharacteristic(
649
- this.platform.Characteristic.On,
650
- bluetoothEnabled
651
- )
652
- }
653
-
654
- if (this.btHeadphonesSwitch && this.currentStatus.bluetoothHp !== undefined) {
655
- const btHeadphonesEnabled = this.currentStatus.bluetoothHp
656
- this.btHeadphonesSwitch.updateCharacteristic(
657
- this.platform.Characteristic.On,
658
- btHeadphonesEnabled
659
- )
660
- }
661
- }
662
-
663
- // Update repeat all from events if available
664
- if (this.currentEvents && this.repeatSwitch) {
665
- const repeatAll = this.currentEvents.repeatAll === 'true'
666
- this.repeatSwitch.updateCharacteristic(
667
- this.platform.Characteristic.On,
668
- repeatAll
669
- )
670
- }
671
-
672
- // Update sleep timer from events if available
673
- if (this.currentEvents && this.sleepTimerService) {
674
- const sleepTimerActive = this.currentEvents.sleepTimerActive === 'true'
675
- this.sleepTimerService.updateCharacteristic(
676
- this.platform.Characteristic.Active,
677
- sleepTimerActive
678
- ? this.platform.Characteristic.Active.ACTIVE
679
- : this.platform.Characteristic.Active.INACTIVE
680
- )
681
-
682
- if (sleepTimerActive && this.currentEvents.sleepTimerSeconds) {
683
- const seconds = parseInt(this.currentEvents.sleepTimerSeconds)
684
- const minutes = Math.round(seconds / 60)
685
- // Map minutes (0-120) to rotation speed (0-100)
686
- const rotationSpeed = Math.min(100, Math.round((minutes / 120) * 100))
687
- this.sleepTimerService.updateCharacteristic(
688
- this.platform.Characteristic.RotationSpeed,
689
- rotationSpeed
690
- )
691
- }
692
- }
693
-
694
- // Update connection status
695
- if (this.connectionService) {
696
- const isOnline = this.isDeviceOnline()
697
- this.connectionService.updateCharacteristic(
698
- this.platform.Characteristic.OccupancyDetected,
699
- isOnline
700
- ? this.platform.Characteristic.OccupancyDetected.OCCUPANCY_DETECTED
701
- : this.platform.Characteristic.OccupancyDetected.OCCUPANCY_NOT_DETECTED
702
- )
703
-
704
- this.connectionService.updateCharacteristic(
705
- this.platform.Characteristic.StatusActive,
706
- isOnline
707
- )
708
- }
709
-
710
- // Update card detection
711
- if (this.cardDetectionService) {
712
- const cardInserted = this.currentStatus.cardInserted !== CARD_INSERTION_STATE.NONE
713
- this.cardDetectionService.updateCharacteristic(
714
- this.platform.Characteristic.ContactSensorState,
715
- cardInserted
716
- ? this.platform.Characteristic.ContactSensorState.CONTACT_DETECTED
717
- : this.platform.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED
718
- )
719
- }
720
- }
721
-
722
- /**
723
- * Update playback-related characteristics
724
- */
725
- updatePlaybackCharacteristics () {
726
- if (!this.currentEvents || !this.speakerService) {
727
- return
728
- }
729
-
730
- // Map playback status to HomeKit media state
731
- let mediaState = this.platform.Characteristic.CurrentMediaState.STOP
732
-
733
- if (this.currentEvents.playbackStatus === PLAYBACK_STATUS.PLAYING) {
734
- mediaState = this.platform.Characteristic.CurrentMediaState.PLAY
735
- } else if (this.currentEvents.playbackStatus === PLAYBACK_STATUS.PAUSED) {
736
- mediaState = this.platform.Characteristic.CurrentMediaState.PAUSE
737
- }
738
-
739
- this.speakerService.updateCharacteristic(
740
- this.platform.Characteristic.CurrentMediaState,
741
- mediaState
742
- )
743
-
744
- this.speakerService.updateCharacteristic(
745
- this.platform.Characteristic.TargetMediaState,
746
- mediaState
747
- )
748
- }
749
-
750
- /**
751
- * Check if device is considered online based on last update time
752
- * @returns {boolean}
753
- */
754
- isDeviceOnline () {
755
- const timeoutMs = (this.platform.config.statusTimeoutSeconds || DEFAULT_CONFIG.statusTimeoutSeconds) * 1000
756
- return Date.now() - this.lastUpdateTime < timeoutMs
757
- }
758
-
759
- // ==================== Characteristic Handlers ====================
760
-
761
- /**
762
- * Get current media state
763
- * @returns {Promise<CharacteristicValue>}
764
- */
765
- async getCurrentMediaState () {
766
- if (!this.currentEvents) {
767
- return this.platform.Characteristic.CurrentMediaState.STOP
768
- }
769
-
770
- if (this.currentEvents.playbackStatus === PLAYBACK_STATUS.PLAYING) {
771
- return this.platform.Characteristic.CurrentMediaState.PLAY
772
- } else if (this.currentEvents.playbackStatus === PLAYBACK_STATUS.PAUSED) {
773
- return this.platform.Characteristic.CurrentMediaState.PAUSE
774
- }
775
-
776
- return this.platform.Characteristic.CurrentMediaState.STOP
777
- }
778
-
779
- /**
780
- * Get target media state
781
- * @returns {Promise<CharacteristicValue>}
782
- */
783
- async getTargetMediaState () {
784
- // Target state follows current state
785
- return this.getCurrentMediaState()
786
- }
787
-
788
- /**
789
- * Set target media state (play/pause/stop)
790
- * @param {CharacteristicValue} value - Target state
791
- */
792
- async setTargetMediaState (value) {
793
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Set target media state:`, value)
794
-
795
- try {
796
- if (value === this.platform.Characteristic.TargetMediaState.PLAY) {
797
- await this.mqtt.resumeCard(this.device.deviceId)
798
- } else if (value === this.platform.Characteristic.TargetMediaState.PAUSE) {
799
- await this.mqtt.pauseCard(this.device.deviceId)
800
- } else if (value === this.platform.Characteristic.TargetMediaState.STOP) {
801
- await this.mqtt.stopCard(this.device.deviceId)
802
- }
803
- } catch (error) {
804
- this.log.error(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Failed to set media state:`, error)
805
- throw new this.platform.api.hap.HapStatusError(
806
- this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
807
- )
808
- }
809
- }
810
-
811
- /**
812
- * Get volume
813
- * @returns {Promise<CharacteristicValue>}
814
- */
815
- async getVolume () {
816
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] getVolume - currentEvents:`, this.currentEvents)
817
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] getVolume - events.volume:`, this.currentEvents?.volume)
818
-
819
- // Volume comes from events, not status
820
- if (!this.currentEvents || this.currentEvents.volume === undefined) {
821
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] getVolume - no volume in events, returning default: 50`)
822
- return 50
823
- }
824
- const volume = Number(this.currentEvents.volume) || 50
825
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] getVolume - returning volume:`, volume)
826
- return volume
827
- }
828
-
829
- /**
830
- * Set volume
831
- * @param {CharacteristicValue} value - Volume level (0-100)
832
- */
833
- async setVolume (value) {
834
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Set volume:`, value)
835
-
836
- try {
837
- await this.mqtt.setVolume(this.device.deviceId, Number(value))
838
- } catch (error) {
839
- this.log.error(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Failed to set volume:`, error)
840
- throw new this.platform.api.hap.HapStatusError(
841
- this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
842
- )
843
- }
844
- }
845
-
846
- /**
847
- * Get mute state
848
- * @returns {Promise<CharacteristicValue>}
849
- */
850
- async getMute () {
851
- // Volume comes from events, not status
852
- if (!this.currentEvents || this.currentEvents.volume === undefined) {
853
- return false
854
- }
855
- return Number(this.currentEvents.volume) === 0
856
- }
857
-
858
- /**
859
- * Set mute state
860
- * @param {CharacteristicValue} value - Mute state
861
- */
862
- async setMute (value) {
863
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Set mute:`, value)
864
-
865
- try {
866
- if (value) {
867
- // Mute - set volume to 0
868
- await this.mqtt.setVolume(this.device.deviceId, 0)
869
- } else {
870
- // Unmute - restore to a reasonable volume if currently 0
871
- const currentVolume = this.currentEvents?.volume ? Number(this.currentEvents.volume) : 0
872
- const targetVolume = currentVolume === 0 ? 50 : currentVolume
873
- await this.mqtt.setVolume(this.device.deviceId, targetVolume)
874
- }
875
- } catch (error) {
876
- this.log.error(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Failed to set mute:`, error)
877
- throw new this.platform.api.hap.HapStatusError(
878
- this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
879
- )
880
- }
881
- }
882
-
883
- /**
884
- * Get battery level
885
- * @returns {Promise<CharacteristicValue>}
886
- */
887
- async getBatteryLevel () {
888
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] getBatteryLevel - currentStatus:`, this.currentStatus)
889
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] getBatteryLevel - batteryLevel:`, this.currentStatus?.batteryLevel)
890
-
891
- if (!this.currentStatus || this.currentStatus.batteryLevel === undefined) {
892
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] getBatteryLevel - returning default: 100`)
893
- return 100
894
- }
895
- const battery = Number(this.currentStatus.batteryLevel) || 100
896
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] getBatteryLevel - returning:`, battery)
897
- return battery
898
- }
899
-
900
- /**
901
- * Get charging state
902
- * @returns {Promise<CharacteristicValue>}
903
- */
904
- async getChargingState () {
905
- if (!this.currentStatus) {
906
- return this.platform.Characteristic.ChargingState.NOT_CHARGING
907
- }
908
-
909
- return this.currentStatus.charging
910
- ? this.platform.Characteristic.ChargingState.CHARGING
911
- : this.platform.Characteristic.ChargingState.NOT_CHARGING
912
- }
913
-
914
- /**
915
- * Get low battery status
916
- * @returns {Promise<CharacteristicValue>}
917
- */
918
- async getStatusLowBattery () {
919
- if (!this.currentStatus) {
920
- return this.platform.Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL
921
- }
922
-
923
- return this.currentStatus.batteryLevel < LOW_BATTERY_THRESHOLD
924
- ? this.platform.Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW
925
- : this.platform.Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL
926
- }
927
-
928
- /**
929
- * Get current temperature
930
- * @returns {Promise<CharacteristicValue>}
931
- */
932
- async getCurrentTemperature () {
933
- if (!this.currentStatus || !this.currentStatus.temp) {
934
- return 20 // Default room temperature
935
- }
936
-
937
- // Parse temp string format "x:temp:y" to get middle value (actual temperature)
938
- const tempParts = this.currentStatus.temp.split(':')
939
- if (tempParts.length < 2) {
940
- return 20
941
- }
942
-
943
- const temp = Number(tempParts[1])
944
- if (isNaN(temp)) {
945
- return 20
946
- }
947
-
948
- // Clamp to HomeKit's valid range: 0-100°C
949
- return Math.max(0, Math.min(100, temp))
950
- }
951
-
952
- /**
953
- * Get occupancy detected (connection status)
954
- * @returns {Promise<CharacteristicValue>}
955
- */
956
- async getOccupancyDetected () {
957
- const isOnline = this.isDeviceOnline()
958
- return isOnline
959
- ? this.platform.Characteristic.OccupancyDetected.OCCUPANCY_DETECTED
960
- : this.platform.Characteristic.OccupancyDetected.OCCUPANCY_NOT_DETECTED
961
- }
962
-
963
- /**
964
- * Get status active (connection status)
965
- * @returns {Promise<CharacteristicValue>}
966
- */
967
- async getStatusActive () {
968
- return this.isDeviceOnline()
969
- }
970
-
971
- /**
972
- * Get contact sensor state (card detection)
973
- * @returns {Promise<CharacteristicValue>}
974
- */
975
- async getContactSensorState () {
976
- if (!this.currentStatus) {
977
- return this.platform.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED
978
- }
979
-
980
- const cardInserted = this.currentStatus.cardInserted !== CARD_INSERTION_STATE.NONE
981
- return cardInserted
982
- ? this.platform.Characteristic.ContactSensorState.CONTACT_DETECTED
983
- : this.platform.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED
984
- }
985
-
986
- /**
987
- * Get display on state
988
- * @returns {Promise<CharacteristicValue>}
989
- */
990
- async getDisplayOn () {
991
- if (!this.currentStatus || this.currentStatus.dnowBrightness === undefined) {
992
- return true
993
- }
994
- return this.currentStatus.dnowBrightness > 0
995
- }
996
-
997
- /**
998
- * Set display on/off state
999
- * @param {CharacteristicValue} value - On/off state
1000
- */
1001
- async setDisplayOn (value) {
1002
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Set display on:`, value)
1003
-
1004
- try {
1005
- // Refresh config if not cached
1006
- if (!this.deviceConfig) {
1007
- this.deviceConfig = await this.platform.yotoApi.getDeviceConfig(this.device.deviceId)
1008
- }
1009
-
1010
- if (value) {
1011
- // Turn on - restore to previous brightness or default
1012
- const brightness = this.currentStatus?.dnowBrightness || 100
1013
- this.deviceConfig.config.dayDisplayBrightness = String(brightness)
1014
- this.deviceConfig.config.nightDisplayBrightness = String(brightness)
1015
- } else {
1016
- // Turn off - set brightness to 0
1017
- this.deviceConfig.config.dayDisplayBrightness = '0'
1018
- this.deviceConfig.config.nightDisplayBrightness = '0'
1019
- }
1020
-
1021
- await this.platform.yotoApi.updateDeviceConfig(this.device.deviceId, this.deviceConfig)
1022
- } catch (error) {
1023
- this.log.error(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Failed to set display on:`, error)
1024
- throw new this.platform.api.hap.HapStatusError(
1025
- this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1026
- )
1027
- }
1028
- }
1029
-
1030
- /**
1031
- * Get display brightness
1032
- * @returns {Promise<CharacteristicValue>}
1033
- */
1034
- async getDisplayBrightness () {
1035
- if (!this.currentStatus || this.currentStatus.dnowBrightness === undefined) {
1036
- return 100
1037
- }
1038
-
1039
- // Use current brightness value (0-100)
1040
- const brightness = Number(this.currentStatus.dnowBrightness)
1041
- if (isNaN(brightness)) {
1042
- return 100
1043
- }
1044
- return Math.min(100, Math.max(0, brightness))
1045
- }
1046
-
1047
- /**
1048
- * Set display brightness
1049
- * @param {CharacteristicValue} value - Brightness level (0-100)
1050
- */
1051
- async setDisplayBrightness (value) {
1052
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Set display brightness:`, value)
1053
-
1054
- try {
1055
- // Refresh config if not cached
1056
- if (!this.deviceConfig) {
1057
- this.deviceConfig = await this.platform.yotoApi.getDeviceConfig(this.device.deviceId)
1058
- }
1059
-
1060
- // Clamp brightness to 0-100
1061
- const brightness = Math.max(0, Math.min(100, Number(value)))
1062
-
1063
- // Update both day and night brightness
1064
- this.deviceConfig.config.dayDisplayBrightness = String(brightness)
1065
- this.deviceConfig.config.nightDisplayBrightness = String(brightness)
1066
-
1067
- await this.platform.yotoApi.updateDeviceConfig(this.device.deviceId, this.deviceConfig)
1068
- } catch (error) {
1069
- this.log.error(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Failed to set display brightness:`, error)
1070
- throw new this.platform.api.hap.HapStatusError(
1071
- this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1072
- )
1073
- }
1074
- }
1075
-
1076
- /**
1077
- * Get Bluetooth enabled state
1078
- * @returns {Promise<CharacteristicValue>}
1079
- */
1080
- async getBluetoothEnabled () {
1081
- if (!this.currentStatus || this.currentStatus.bluetoothHp === undefined) {
1082
- return false
1083
- }
1084
- return this.currentStatus.bluetoothHp
1085
- }
1086
-
1087
- /**
1088
- * Set Bluetooth enabled state
1089
- * @param {CharacteristicValue} value - Enabled state
1090
- */
1091
- async setBluetoothEnabled (value) {
1092
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Set Bluetooth:`, value)
1093
-
1094
- try {
1095
- // Refresh config if not cached
1096
- if (!this.deviceConfig) {
1097
- this.deviceConfig = await this.platform.yotoApi.getDeviceConfig(this.device.deviceId)
1098
- }
1099
-
1100
- this.deviceConfig.config.bluetoothEnabled = value ? 'true' : 'false'
1101
- await this.platform.yotoApi.updateDeviceConfig(this.device.deviceId, this.deviceConfig)
1102
- } catch (error) {
1103
- this.log.error(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Failed to set Bluetooth:`, error)
1104
- throw new this.platform.api.hap.HapStatusError(
1105
- this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1106
- )
1107
- }
1108
- }
1109
-
1110
- /**
1111
- * Get repeat all state
1112
- * @returns {Promise<CharacteristicValue>}
1113
- */
1114
- async getRepeatAll () {
1115
- if (!this.currentEvents) {
1116
- return false
1117
- }
1118
- return this.currentEvents.repeatAll === 'true'
1119
- }
1120
-
1121
- /**
1122
- * Set repeat all state
1123
- * @param {CharacteristicValue} value - Repeat state
1124
- */
1125
- async setRepeatAll (value) {
1126
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Set repeat all:`, value)
1127
-
1128
- try {
1129
- // Refresh config if not cached
1130
- if (!this.deviceConfig) {
1131
- this.deviceConfig = await this.platform.yotoApi.getDeviceConfig(this.device.deviceId)
1132
- }
1133
-
1134
- this.deviceConfig.config.repeatAll = Boolean(value)
1135
- await this.platform.yotoApi.updateDeviceConfig(this.device.deviceId, this.deviceConfig)
1136
- } catch (error) {
1137
- this.log.error(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Failed to set repeat all:`, error)
1138
- throw new this.platform.api.hap.HapStatusError(
1139
- this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1140
- )
1141
- }
1142
- }
1143
-
1144
- /**
1145
- * Get Bluetooth headphones enabled state
1146
- * @returns {Promise<CharacteristicValue>}
1147
- */
1148
- async getBtHeadphonesEnabled () {
1149
- if (!this.currentStatus || this.currentStatus.bluetoothHp === undefined) {
1150
- return false
1151
- }
1152
- return this.currentStatus.bluetoothHp
1153
- }
1154
-
1155
- /**
1156
- * Set Bluetooth headphones enabled state
1157
- * @param {CharacteristicValue} value - Enabled state
1158
- */
1159
- async setBtHeadphonesEnabled (value) {
1160
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Set BT headphones:`, value)
1161
-
1162
- try {
1163
- // Refresh config if not cached
1164
- if (!this.deviceConfig) {
1165
- this.deviceConfig = await this.platform.yotoApi.getDeviceConfig(this.device.deviceId)
1166
- }
1167
-
1168
- this.deviceConfig.config.btHeadphonesEnabled = Boolean(value)
1169
- await this.platform.yotoApi.updateDeviceConfig(this.device.deviceId, this.deviceConfig)
1170
- } catch (error) {
1171
- this.log.error(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Failed to set BT headphones:`, error)
1172
- throw new this.platform.api.hap.HapStatusError(
1173
- this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1174
- )
1175
- }
1176
- }
1177
-
1178
- /**
1179
- * Get sleep timer active state
1180
- * @returns {Promise<CharacteristicValue>}
1181
- */
1182
- async getSleepTimerActive () {
1183
- if (!this.currentEvents) {
1184
- return this.platform.Characteristic.Active.INACTIVE
1185
- }
1186
- return this.currentEvents.sleepTimerActive === 'true'
1187
- ? this.platform.Characteristic.Active.ACTIVE
1188
- : this.platform.Characteristic.Active.INACTIVE
1189
- }
1190
-
1191
- /**
1192
- * Set sleep timer active state
1193
- * @param {CharacteristicValue} value - Active state
1194
- */
1195
- async setSleepTimerActive (value) {
1196
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Set sleep timer active:`, value)
1197
-
1198
- try {
1199
- if (value === this.platform.Characteristic.Active.INACTIVE) {
1200
- // Turn off timer
1201
- await this.mqtt.setSleepTimer(this.device.deviceId, 0)
1202
- } else {
1203
- // Turn on with default duration (30 minutes)
1204
- await this.mqtt.setSleepTimer(this.device.deviceId, 30 * 60)
1205
- }
1206
- } catch (error) {
1207
- this.log.error(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Failed to set sleep timer active:`, error)
1208
- throw new this.platform.api.hap.HapStatusError(
1209
- this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1210
- )
1211
- }
1212
- }
1213
-
1214
- /**
1215
- * Get sleep timer minutes (as rotation speed)
1216
- * @returns {Promise<CharacteristicValue>}
1217
- */
1218
- async getSleepTimerMinutes () {
1219
- if (!this.currentEvents || this.currentEvents.sleepTimerActive !== 'true') {
1220
- return 0
1221
- }
1222
-
1223
- const seconds = parseInt(this.currentEvents.sleepTimerSeconds || '0')
1224
- const minutes = Math.round(seconds / 60)
1225
- // Map minutes (0-120) to rotation speed (0-100)
1226
- return Math.min(100, Math.round((minutes / 120) * 100))
1227
- }
1228
-
1229
- /**
1230
- * Set sleep timer minutes (from rotation speed)
1231
- * @param {CharacteristicValue} value - Rotation speed (0-100)
1232
- */
1233
- async setSleepTimerMinutes (value) {
1234
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Set sleep timer minutes:`, value)
1235
-
1236
- try {
1237
- // Map rotation speed (0-100) to minutes (0-120)
1238
- const minutes = Math.round((Number(value) / 100) * 120)
1239
- const seconds = minutes * 60
1240
-
1241
- if (seconds === 0) {
1242
- // Turn off timer
1243
- await this.mqtt.setSleepTimer(this.device.deviceId, 0)
1244
- } else {
1245
- // Set timer with specified duration
1246
- await this.mqtt.setSleepTimer(this.device.deviceId, seconds)
1247
- }
1248
- } catch (error) {
1249
- this.log.error(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Failed to set sleep timer:`, error)
1250
- throw new this.platform.api.hap.HapStatusError(
1251
- this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1252
- )
1253
- }
1254
- }
1255
-
1256
- /**
1257
- * Get day volume limit enabled state
1258
- * @returns {Promise<CharacteristicValue>}
1259
- */
1260
- async getDayVolumeLimitEnabled () {
1261
- // Always enabled - brightness controls the limit
1262
- return true
1263
- }
1264
-
1265
- /**
1266
- * Set day volume limit enabled state
1267
- * @param {CharacteristicValue} _value - Enabled state (unused)
1268
- */
1269
- async setDayVolumeLimitEnabled (_value) {
1270
- // No-op - always enabled, use brightness to control
1271
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Day volume limit always enabled`)
1272
- }
1273
-
1274
- /**
1275
- * Get day volume limit (0-16 mapped to 0-100)
1276
- * @returns {Promise<CharacteristicValue>}
1277
- */
1278
- async getDayVolumeLimit () {
1279
- if (!this.deviceConfig) {
1280
- return 100 // Default to max
1281
- }
1282
-
1283
- try {
1284
- const limit = parseInt(this.deviceConfig.config.maxVolumeLimit || '16')
1285
- if (isNaN(limit)) {
1286
- return 100
1287
- }
1288
- // Map 0-16 to 0-100
1289
- return Math.round((limit / 16) * 100)
1290
- } catch (error) {
1291
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Failed to parse day volume limit:`, error)
1292
- return 100
1293
- }
1294
- }
1295
-
1296
- /**
1297
- * Set day volume limit (0-100 mapped to 0-16)
1298
- * @param {CharacteristicValue} value - Brightness value (0-100)
1299
- */
1300
- async setDayVolumeLimit (value) {
1301
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Set day volume limit:`, value)
1302
-
1303
- try {
1304
- // Refresh config if not cached
1305
- if (!this.deviceConfig) {
1306
- this.deviceConfig = await this.platform.yotoApi.getDeviceConfig(this.device.deviceId)
1307
- }
1308
-
1309
- // Map 0-100 to 0-16
1310
- const limit = Math.round((Number(value) / 100) * 16)
1311
- this.deviceConfig.config.maxVolumeLimit = String(limit)
1312
- await this.platform.yotoApi.updateDeviceConfig(this.device.deviceId, this.deviceConfig)
1313
- } catch (error) {
1314
- this.log.error(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Failed to set day volume limit:`, error)
1315
- throw new this.platform.api.hap.HapStatusError(
1316
- this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1317
- )
1318
- }
1319
- }
1320
-
1321
- /**
1322
- * Get night volume limit enabled state
1323
- * @returns {Promise<CharacteristicValue>}
1324
- */
1325
- async getNightVolumeLimitEnabled () {
1326
- // Always enabled - brightness controls the limit
1327
- return true
1328
- }
1329
-
1330
- /**
1331
- * Set night volume limit enabled state
1332
- * @param {CharacteristicValue} _value - Enabled state (unused)
1333
- */
1334
- async setNightVolumeLimitEnabled (_value) {
1335
- // No-op - always enabled, use brightness to control
1336
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Night volume limit always enabled`)
1337
- }
1338
-
1339
- /**
1340
- * Get night volume limit (0-16 mapped to 0-100)
1341
- * @returns {Promise<CharacteristicValue>}
1342
- */
1343
- async getNightVolumeLimit () {
1344
- if (!this.deviceConfig) {
1345
- return 100 // Default to max
1346
- }
1347
-
1348
- try {
1349
- const limit = parseInt(this.deviceConfig.config.nightMaxVolumeLimit || '16')
1350
- if (isNaN(limit)) {
1351
- return 100
1352
- }
1353
- // Map 0-16 to 0-100
1354
- return Math.round((limit / 16) * 100)
1355
- } catch (error) {
1356
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Failed to parse night volume limit:`, error)
1357
- return 100
1358
- }
1359
- }
1360
-
1361
- /**
1362
- * Set night volume limit (0-100 mapped to 0-16)
1363
- * @param {CharacteristicValue} value - Brightness value (0-100)
1364
- */
1365
- async setNightVolumeLimit (value) {
1366
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Set night volume limit:`, value)
1367
-
1368
- try {
1369
- // Refresh config if not cached
1370
- if (!this.deviceConfig) {
1371
- this.deviceConfig = await this.platform.yotoApi.getDeviceConfig(this.device.deviceId)
1372
- }
1373
-
1374
- // Map 0-100 to 0-16
1375
- const limit = Math.round((Number(value) / 100) * 16)
1376
- this.deviceConfig.config.nightMaxVolumeLimit = String(limit)
1377
- await this.platform.yotoApi.updateDeviceConfig(this.device.deviceId, this.deviceConfig)
1378
- } catch (error) {
1379
- this.log.error(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Failed to set night volume limit:`, error)
1380
- throw new this.platform.api.hap.HapStatusError(
1381
- this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1382
- )
1383
- }
1384
- }
1385
-
1386
- /**
1387
- * Get ambient light on state
1388
- * @returns {Promise<CharacteristicValue>}
1389
- */
1390
- async getAmbientLightOn () {
1391
- if (!this.deviceConfig) {
1392
- return false // Default to off
1393
- }
1394
-
1395
- try {
1396
- const color = this.deviceConfig.config.ambientColour || '000000'
1397
- // Consider light "on" if not pure black
1398
- return color !== '000000'
1399
- } catch (error) {
1400
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Failed to parse ambient light state:`, error)
1401
- return false
1402
- }
1403
- }
1404
-
1405
- /**
1406
- * Set ambient light on/off state
1407
- * @param {CharacteristicValue} value - On/off state
1408
- */
1409
- async setAmbientLightOn (value) {
1410
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Set ambient light on:`, value)
1411
-
1412
- try {
1413
- // Refresh config if not cached
1414
- if (!this.deviceConfig) {
1415
- this.deviceConfig = await this.platform.yotoApi.getDeviceConfig(this.device.deviceId)
1416
- }
1417
-
1418
- if (value) {
1419
- // Restore to a default color (warm white) when turning on
1420
- await this.updateAmbientLightColor()
1421
- } else {
1422
- // Turn off by setting to black
1423
- await this.mqtt.setAmbientLight(this.device.deviceId, 0, 0, 0)
1424
- }
1425
- } catch (error) {
1426
- this.log.error(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Failed to set ambient light on:`, error)
1427
- throw new this.platform.api.hap.HapStatusError(
1428
- this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1429
- )
1430
- }
1431
- }
1432
-
1433
- /**
1434
- * Get ambient light hue
1435
- * @returns {Promise<CharacteristicValue>}
1436
- */
1437
- async getAmbientLightHue () {
1438
- if (!this.deviceConfig) {
1439
- return 0 // Default hue
1440
- }
1441
-
1442
- try {
1443
- const hex = this.deviceConfig.config.ambientColour || '000000'
1444
- const { h } = this.hexToHsv(hex)
1445
- if (isNaN(h)) {
1446
- return 0
1447
- }
1448
- return h
1449
- } catch (error) {
1450
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Failed to parse ambient light hue:`, error)
1451
- return 0
1452
- }
1453
- }
1454
-
1455
- /**
1456
- * Set ambient light hue
1457
- * @param {CharacteristicValue} value - Hue value (0-360)
1458
- */
1459
- async setAmbientLightHue (value) {
1460
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Set ambient light hue:`, value)
1461
- // Store for combined update with saturation and brightness
1462
- this.pendingAmbientHue = Number(value)
1463
- await this.updateAmbientLightColor()
1464
- }
1465
-
1466
- /**
1467
- * Get ambient light saturation
1468
- * @returns {Promise<CharacteristicValue>}
1469
- */
1470
- async getAmbientLightSaturation () {
1471
- if (!this.deviceConfig) {
1472
- return 0 // Default saturation
1473
- }
1474
-
1475
- try {
1476
- const hex = this.deviceConfig.config.ambientColour || '000000'
1477
- const { s } = this.hexToHsv(hex)
1478
- if (isNaN(s)) {
1479
- return 0
1480
- }
1481
- return s
1482
- } catch (error) {
1483
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Failed to parse ambient light saturation:`, error)
1484
- return 0
1485
- }
1486
- }
1487
-
1488
- /**
1489
- * Set ambient light saturation
1490
- * @param {CharacteristicValue} value - Saturation value (0-100)
1491
- */
1492
- async setAmbientLightSaturation (value) {
1493
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Set ambient light saturation:`, value)
1494
- // Store for combined update with hue and brightness
1495
- this.pendingAmbientSaturation = Number(value)
1496
- await this.updateAmbientLightColor()
1497
- }
1498
-
1499
- /**
1500
- * Get ambient light brightness
1501
- * @returns {Promise<CharacteristicValue>}
1502
- */
1503
- async getAmbientLightBrightness () {
1504
- if (!this.deviceConfig) {
1505
- return 0 // Default brightness
1506
- }
1507
-
1508
- try {
1509
- const hex = this.deviceConfig.config.ambientColour || '000000'
1510
- const { v } = this.hexToHsv(hex)
1511
- const brightness = Math.round(v)
1512
- if (isNaN(brightness)) {
1513
- return 0
1514
- }
1515
- return brightness
1516
- } catch (error) {
1517
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Failed to parse ambient brightness:`, error)
1518
- return 0
1519
- }
1520
- }
1521
-
1522
- /**
1523
- * Set ambient light brightness
1524
- * @param {CharacteristicValue} value - Brightness value (0-100)
1525
- */
1526
- async setAmbientLightBrightness (value) {
1527
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Set ambient light brightness:`, value)
1528
- // Store for combined update with hue and saturation
1529
- this.pendingAmbientBrightness = Number(value)
1530
- await this.updateAmbientLightColor()
1531
- }
1532
-
1533
- /**
1534
- * Update ambient light color using stored HSV values
1535
- */
1536
- async updateAmbientLightColor () {
1537
- try {
1538
- // Refresh config if not cached
1539
- if (!this.deviceConfig) {
1540
- this.deviceConfig = await this.platform.yotoApi.getDeviceConfig(this.device.deviceId)
1541
- }
1542
-
1543
- const currentHex = this.deviceConfig.config.ambientColour || '000000'
1544
- const currentHsv = this.hexToHsv(currentHex)
1545
-
1546
- // Use pending values or current values
1547
- const h = this.pendingAmbientHue !== undefined ? this.pendingAmbientHue : currentHsv.h
1548
- const s = this.pendingAmbientSaturation !== undefined ? this.pendingAmbientSaturation : currentHsv.s
1549
- const v = this.pendingAmbientBrightness !== undefined ? this.pendingAmbientBrightness : currentHsv.v
1550
-
1551
- // Convert HSV to RGB
1552
- const { r, g, b } = this.hsvToRgb(h, s, v)
1553
-
1554
- // Send MQTT command
1555
- await this.mqtt.setAmbientLight(this.device.deviceId, r, g, b)
1556
-
1557
- // Clear pending values
1558
- this.pendingAmbientHue = undefined
1559
- this.pendingAmbientSaturation = undefined
1560
- this.pendingAmbientBrightness = undefined
1561
- } catch (error) {
1562
- this.log.error(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Failed to update ambient light:`, error)
1563
- }
1564
- }
1565
-
1566
- /**
1567
- * Convert hex color to HSV
1568
- * @param {string} hex - Hex color string (e.g., "#ff3900")
1569
- * @returns {{ h: number, s: number, v: number }} HSV values (h: 0-360, s: 0-100, v: 0-100)
1570
- */
1571
- hexToHsv (hex) {
1572
- // Remove # if present
1573
- hex = hex.replace('#', '')
1574
-
1575
- // Parse RGB
1576
- const r = parseInt(hex.substring(0, 2), 16) / 255
1577
- const g = parseInt(hex.substring(2, 4), 16) / 255
1578
- const b = parseInt(hex.substring(4, 6), 16) / 255
1579
-
1580
- const max = Math.max(r, g, b)
1581
- const min = Math.min(r, g, b)
1582
- const delta = max - min
1583
-
1584
- let h = 0
1585
- let s = 0
1586
- const v = max
1587
-
1588
- if (delta !== 0) {
1589
- s = delta / max
1590
-
1591
- if (max === r) {
1592
- h = ((g - b) / delta + (g < b ? 6 : 0)) / 6
1593
- } else if (max === g) {
1594
- h = ((b - r) / delta + 2) / 6
1595
- } else {
1596
- h = ((r - g) / delta + 4) / 6
1597
- }
1598
- }
1599
-
1600
- return {
1601
- h: Math.round(h * 360),
1602
- s: Math.round(s * 100),
1603
- v: Math.round(v * 100)
1604
- }
1605
- }
1606
-
1607
- /**
1608
- * Convert HSV to RGB
1609
- * @param {number} h - Hue (0-360)
1610
- * @param {number} s - Saturation (0-100)
1611
- * @param {number} v - Value/Brightness (0-100)
1612
- * @returns {{ r: number, g: number, b: number }} RGB values (0-255)
1613
- */
1614
- hsvToRgb (h, s, v) {
1615
- h = h / 360
1616
- s = s / 100
1617
- v = v / 100
1618
-
1619
- let r, g, b
1620
-
1621
- const i = Math.floor(h * 6)
1622
- const f = h * 6 - i
1623
- const p = v * (1 - s)
1624
- const q = v * (1 - f * s)
1625
- const t = v * (1 - (1 - f) * s)
1626
-
1627
- switch (i % 6) {
1628
- case 0: r = v; g = t; b = p; break
1629
- case 1: r = q; g = v; b = p; break
1630
- case 2: r = p; g = v; b = t; break
1631
- case 3: r = p; g = q; b = v; break
1632
- case 4: r = t; g = p; b = v; break
1633
- case 5: r = v; g = p; b = q; break
1634
- default: r = 0; g = 0; b = 0
1635
- }
1636
-
1637
- return {
1638
- r: Math.round(r * 255),
1639
- g: Math.round(g * 255),
1640
- b: Math.round(b * 255)
1641
- }
1642
- }
1643
-
1644
- /**
1645
- * Handle active content change
1646
- * @param {string} cardId - Card ID
1647
- * @param {YotoPlaybackEvents} [events] - Optional event data with track/chapter info
1648
- */
1649
- async handleActiveContentChange (cardId, events) {
1650
- // Skip if same card
1651
- if (this.activeContentCardId === cardId) {
1652
- return
1653
- }
1654
-
1655
- this.activeContentCardId = cardId
1656
-
1657
- // Handle no card inserted
1658
- if (!cardId || cardId === 'none' || cardId === 'null') {
1659
- this.log.info(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] No card inserted`)
1660
- this.activeContentInfo = null
1661
- this.accessory.context.activeContentInfo = null
1662
- return
1663
- }
1664
-
1665
- this.log.info(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Active card changed: ${cardId}`)
1666
-
1667
- // Use event data if available for immediate content info
1668
- if (events?.trackTitle || events?.chapterTitle) {
1669
- const title = events.trackTitle || events.chapterTitle || cardId
1670
- this.log.info(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Now playing: "${title}"`)
1671
-
1672
- if (events.chapterKey && events.chapterKey !== events.chapterTitle) {
1673
- this.log.info(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Chapter: ${events.chapterKey}`)
1674
- }
1675
-
1676
- // Optionally update accessory display name with current content
1677
- if (this.platform.config.updateAccessoryName) {
1678
- this.accessory.displayName = `${this.accessory.displayName} - ${title}`
1679
- this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Updated display name`)
1680
- }
1681
- }
1682
-
1683
- // Check if we own this card to fetch additional details
1684
- if (!this.platform.isCardOwned(cardId)) {
1685
- this.log.info(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Card ${cardId} (not in library - using event data)`)
1686
- this.activeContentInfo = null
1687
- this.accessory.context.activeContentInfo = null
1688
- return
1689
- }
1690
-
1691
- // Attempt to fetch full card details for owned cards
1692
- try {
1693
- const content = await this.platform.yotoApi.getContent(cardId)
1694
- this.activeContentInfo = content
1695
-
1696
- // Log additional metadata from API
1697
- if (content.card?.metadata?.author) {
1698
- this.log.info(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Author: ${content.card.metadata.author}`)
1699
- }
1700
-
1701
- if (content.card?.metadata?.category) {
1702
- this.log.info(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Category: ${content.card.metadata.category}`)
1703
- }
1704
-
1705
- // Store in context for persistence
1706
- this.accessory.context.activeContentInfo = this.activeContentInfo
1707
- } catch (error) {
1708
- // Handle 403 Forbidden (store-bought cards user doesn't own)
1709
- if (error instanceof Error && error.message.includes('403')) {
1710
- this.log.info(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Card ${cardId} API access denied (using event data)`)
1711
- this.activeContentInfo = null
1712
- this.accessory.context.activeContentInfo = null
1713
- } else if (error instanceof Error && error.message.includes('404')) {
1714
- // Card not found - might be deleted or invalid
1715
- this.log.warn(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Card ${cardId} not found in API`)
1716
- this.activeContentInfo = null
1717
- this.accessory.context.activeContentInfo = null
1718
- } else {
1719
- // Other errors
1720
- this.log.error(LOG_PREFIX.ACCESSORY, `[${this.accessory.displayName}] Failed to fetch content details:`, error)
1721
- }
1722
- }
1723
- }
1724
- }