homebridge-yoto 0.0.1 → 0.0.3

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