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.
@@ -0,0 +1,1870 @@
1
+ /**
2
+ * @fileoverview Yoto Player Accessory implementation - handles HomeKit services for a single player
3
+ */
4
+
5
+ /** @import { PlatformAccessory, CharacteristicValue, Service, Logger } from 'homebridge' */
6
+ /** @import { YotoPlatform } from './platform.js' */
7
+ /** @import { YotoDeviceModel } from 'yoto-nodejs-client' */
8
+ /** @import { YotoDevice } from 'yoto-nodejs-client/lib/api-endpoints/devices.js' */
9
+ /** @import { YotoAccessoryContext } from './platform.js' */
10
+
11
+ /**
12
+ * Device capabilities detected from metadata
13
+ * @typedef {Object} YotoDeviceCapabilities
14
+ * @property {boolean} hasTemperatureSensor - Whether device has temperature sensor (Gen3 only)
15
+ * @property {string | undefined} formFactor - Device form factor ('standard' or 'mini')
16
+ * @property {string | undefined} generation - Device generation (e.g., 'gen3')
17
+ */
18
+
19
+ import convert from 'color-convert'
20
+ import {
21
+ DEFAULT_MANUFACTURER,
22
+ DEFAULT_MODEL,
23
+ LOW_BATTERY_THRESHOLD,
24
+ LOG_PREFIX,
25
+ } from './constants.js'
26
+ import { sanitizeName } from './sanitize-name.js'
27
+ import { syncServiceNames } from './sync-service-names.js'
28
+
29
+ /**
30
+ * Yoto Player Accessory Handler
31
+ * Manages HomeKit services and characteristics for a single Yoto player
32
+ */
33
+ export class YotoPlayerAccessory {
34
+ /** @type {YotoPlatform} */ #platform
35
+ /** @type {PlatformAccessory<YotoAccessoryContext>} */ #accessory
36
+ /** @type {YotoDeviceModel} */ #deviceModel
37
+ /** @type {Logger} */ #log
38
+ /** @type {YotoDevice} */ #device
39
+ /** @type {Service | undefined} */ playbackService
40
+ /** @type {Service | undefined} */ volumeService
41
+ /** @type {Service | undefined} */ batteryService
42
+ /** @type {Service | undefined} */ temperatureSensorService
43
+ /** @type {Service | undefined} */ dayNightlightService
44
+ /** @type {Service | undefined} */ nightNightlightService
45
+ /** @type {Service | undefined} */ nightlightActiveService
46
+ /** @type {Service | undefined} */ dayNightlightActiveService
47
+ /** @type {Service | undefined} */ nightNightlightActiveService
48
+ /** @type {Service | undefined} */ cardSlotService
49
+ /** @type {Service | undefined} */ nightModeService
50
+ /** @type {Service | undefined} */ sleepTimerService
51
+ /** @type {Service | undefined} */ bluetoothService
52
+ /** @type {Service | undefined} */ dayMaxVolumeService
53
+ /** @type {Service | undefined} */ nightMaxVolumeService
54
+ // Volume state for mute/unmute (0-16 steps)
55
+ /** @type {number} */ #lastNonZeroVolume = 8
56
+ // Nightlight color state for restore-on-ON
57
+ /** @type {string} */ #lastDayColor = '0xffffff'
58
+ /** @type {string} */ #lastNightColor = '0xffffff'
59
+ /** @type {Set<Service>} */ #currentServices = new Set()
60
+
61
+ /**
62
+ * @param {Object} params
63
+ * @param {YotoPlatform} params.platform - Platform instance
64
+ * @param {PlatformAccessory<YotoAccessoryContext>} params.accessory - Platform accessory
65
+ * @param {YotoDeviceModel} params.deviceModel - Yoto device model with live state
66
+ */
67
+ constructor ({ platform, accessory, deviceModel }) {
68
+ this.#platform = platform
69
+ this.#accessory = accessory
70
+ this.#deviceModel = deviceModel
71
+ this.#log = platform.log
72
+
73
+ // Extract device info from context
74
+ this.#device = accessory.context.device
75
+
76
+ // Track all services we add during setup
77
+ this.#currentServices = new Set()
78
+ }
79
+
80
+ /**
81
+ * Setup accessory - create services and setup event listeners
82
+ * @returns {Promise<void>}
83
+ */
84
+ async setup () {
85
+ this.#log.info(LOG_PREFIX.ACCESSORY, `Setting up ${this.#device.name}`)
86
+
87
+ // Check if device type is supported
88
+ if (!this.#deviceModel.capabilities.supported) {
89
+ this.#log.warn(
90
+ LOG_PREFIX.ACCESSORY,
91
+ `[${this.#device.name}] Unknown device type '${this.#device.deviceType}' - some features may not work correctly`
92
+ )
93
+ }
94
+
95
+ // Clear the set before setup (in case setup is called multiple times)
96
+ this.#currentServices.clear()
97
+
98
+ // 1. Setup services
99
+ this.setupAccessoryInformation()
100
+ this.setupPlaybackServices()
101
+ this.setupBatteryService()
102
+
103
+ // Setup optional services based on device capabilities
104
+ if (this.#deviceModel.capabilities.hasTemperatureSensor) {
105
+ this.setupTemperatureSensorService()
106
+ }
107
+
108
+ if (this.#deviceModel.capabilities.hasColoredNightlight) {
109
+ this.setupNightlightServices()
110
+ }
111
+
112
+ // Setup universal services (available on all devices)
113
+ this.setupCardSlotService()
114
+ this.setupNightModeService()
115
+ this.setupSleepTimerService()
116
+ this.setupBluetoothService()
117
+ this.setupVolumeLimitServices()
118
+
119
+ // Remove any services that aren't in our current set
120
+ // (except AccessoryInformation which should always be preserved)
121
+ for (const service of this.#accessory.services) {
122
+ if (service.UUID !== this.#platform.Service.AccessoryInformation.UUID &&
123
+ !this.#currentServices.has(service)) {
124
+ this.#log.info(LOG_PREFIX.ACCESSORY, `Removing stale service: ${service.displayName || service.UUID}`)
125
+ this.#accessory.removeService(service)
126
+ }
127
+ }
128
+
129
+ // 2. Setup event listeners for device model updates
130
+ this.setupEventListeners()
131
+
132
+ this.#log.info(LOG_PREFIX.ACCESSORY, `✓ ${this.#device.name} ready`)
133
+ }
134
+
135
+ /**
136
+ * Generate service name with device name prefix
137
+ * @param {string} serviceName - Base service name
138
+ * @returns {string} Full service name with device prefix
139
+ */
140
+ generateServiceName (serviceName) {
141
+ const rawName = `${this.#device.name} ${serviceName}`
142
+ return sanitizeName(rawName)
143
+ }
144
+
145
+ /**
146
+ * Setup AccessoryInformation service
147
+ */
148
+ setupAccessoryInformation () {
149
+ const { Service, Characteristic } = this.#platform
150
+ const service = this.#accessory.getService(Service.AccessoryInformation) ||
151
+ this.#accessory.addService(Service.AccessoryInformation)
152
+
153
+ // Build hardware revision from generation and form factor
154
+ const hardwareRevision = [
155
+ this.#device.generation,
156
+ this.#device.formFactor,
157
+ ].filter(Boolean).join(' ') || 'Unknown'
158
+
159
+ // Use deviceFamily for model (e.g., 'v2', 'v3', 'mini')
160
+ const model = this.#device.deviceFamily || this.#device.deviceType || DEFAULT_MODEL
161
+
162
+ // Set standard characteristics
163
+ service
164
+ .setCharacteristic(Characteristic.Manufacturer, DEFAULT_MANUFACTURER)
165
+ .setCharacteristic(Characteristic.Model, model)
166
+ .setCharacteristic(Characteristic.SerialNumber, this.#device.deviceId)
167
+ .setCharacteristic(Characteristic.HardwareRevision, hardwareRevision)
168
+
169
+ // Set firmware version from live status if available
170
+ if (this.#deviceModel.status.firmwareVersion) {
171
+ service.setCharacteristic(
172
+ Characteristic.FirmwareRevision,
173
+ this.#deviceModel.status.firmwareVersion
174
+ )
175
+ }
176
+
177
+ this.#currentServices.add(service)
178
+ }
179
+
180
+ /**
181
+ * Setup basic playback services (non-SmartSpeaker)
182
+ */
183
+ setupPlaybackServices () {
184
+ this.setupPlaybackSwitchService()
185
+ this.setupVolumeService()
186
+ }
187
+
188
+ /**
189
+ * Setup play/pause Switch service (PRIMARY)
190
+ */
191
+ setupPlaybackSwitchService () {
192
+ const { Service, Characteristic } = this.#platform
193
+ const serviceName = this.generateServiceName('Playback')
194
+
195
+ const service = this.#accessory.getServiceById(Service.Switch, 'Playback') ||
196
+ this.#accessory.addService(Service.Switch, serviceName, 'Playback')
197
+
198
+ service.setPrimaryService(true)
199
+
200
+ syncServiceNames({ Characteristic, service, name: serviceName })
201
+
202
+ service.addOptionalCharacteristic(Characteristic.StatusActive)
203
+ service
204
+ .getCharacteristic(Characteristic.StatusActive)
205
+ .onGet(this.getStatusActive.bind(this))
206
+
207
+ service
208
+ .getCharacteristic(Characteristic.On)
209
+ .onGet(this.getPlaybackOn.bind(this))
210
+ .onSet(this.setPlaybackOn.bind(this))
211
+
212
+ this.playbackService = service
213
+ this.#currentServices.add(service)
214
+ }
215
+
216
+ /**
217
+ * Setup Fanv2 service for volume/mute controls (Speaker service isn't shown in Home)
218
+ */
219
+ setupVolumeService () {
220
+ const { Service, Characteristic } = this.#platform
221
+ const serviceName = this.generateServiceName('Volume')
222
+
223
+ const service = this.#accessory.getServiceById(Service.Fanv2, 'Volume') ||
224
+ this.#accessory.addService(Service.Fanv2, serviceName, 'Volume')
225
+ syncServiceNames({ Characteristic, service, name: serviceName })
226
+
227
+ service.addOptionalCharacteristic(Characteristic.StatusActive)
228
+ service
229
+ .getCharacteristic(Characteristic.StatusActive)
230
+ .onGet(this.getStatusActive.bind(this))
231
+
232
+ service
233
+ .getCharacteristic(Characteristic.Active)
234
+ .onGet(this.getVolumeActive.bind(this))
235
+ .onSet(this.setVolumeActive.bind(this))
236
+
237
+ service
238
+ .getCharacteristic(Characteristic.RotationSpeed)
239
+ .setProps({
240
+ minValue: 0,
241
+ maxValue: 16,
242
+ minStep: 1,
243
+ })
244
+ .onGet(this.getVolume.bind(this))
245
+ .onSet(this.setVolume.bind(this))
246
+
247
+ this.volumeService = service
248
+ this.#currentServices.add(service)
249
+ }
250
+
251
+ /**
252
+ * Setup Battery service
253
+ */
254
+ setupBatteryService () {
255
+ const { Service, Characteristic } = this.#platform
256
+ const serviceName = this.generateServiceName('Battery')
257
+ const service = this.#accessory.getService(Service.Battery) ||
258
+ this.#accessory.addService(Service.Battery, serviceName)
259
+ syncServiceNames({ Characteristic, service, name: serviceName })
260
+
261
+ // BatteryLevel (GET only)
262
+ service.getCharacteristic(Characteristic.BatteryLevel)
263
+ .onGet(this.getBatteryLevel.bind(this))
264
+
265
+ // ChargingState (GET only)
266
+ service.getCharacteristic(Characteristic.ChargingState)
267
+ .onGet(this.getChargingState.bind(this))
268
+
269
+ // StatusLowBattery (GET only)
270
+ service.getCharacteristic(Characteristic.StatusLowBattery)
271
+ .onGet(this.getStatusLowBattery.bind(this))
272
+
273
+ // Battery does not support StatusActive
274
+
275
+ this.batteryService = service
276
+ this.#currentServices.add(service)
277
+ }
278
+
279
+ /**
280
+ * Setup TemperatureSensor service (optional - only for devices with temperature sensor)
281
+ */
282
+ setupTemperatureSensorService () {
283
+ const { Service, Characteristic } = this.#platform
284
+ const serviceName = this.generateServiceName('Temperature')
285
+ const service = this.#accessory.getService(Service.TemperatureSensor) ||
286
+ this.#accessory.addService(Service.TemperatureSensor, serviceName)
287
+ syncServiceNames({ Characteristic, service, name: serviceName })
288
+
289
+ // CurrentTemperature (GET only)
290
+ service.getCharacteristic(Characteristic.CurrentTemperature)
291
+ .onGet(this.getCurrentTemperature.bind(this))
292
+
293
+ // StatusFault (GET only) - indicates if sensor is working
294
+ service.getCharacteristic(Characteristic.StatusFault)
295
+ .onGet(this.getTemperatureSensorFault.bind(this))
296
+
297
+ // StatusActive (online/offline indicator)
298
+ service.getCharacteristic(Characteristic.StatusActive)
299
+ .onGet(this.getStatusActive.bind(this))
300
+
301
+ this.temperatureSensorService = service
302
+ this.#currentServices.add(service)
303
+ }
304
+
305
+ /**
306
+ * Setup Nightlight services (optional - only for devices with colored nightlight)
307
+ * Creates two Lightbulb services for day and night nightlight color control
308
+ */
309
+ setupNightlightServices () {
310
+ const { Service, Characteristic } = this.#platform
311
+
312
+ // Day Nightlight
313
+ const dayName = this.generateServiceName('Day Nightlight')
314
+ const dayService = this.#accessory.getServiceById(Service.Lightbulb, 'DayNightlight') ||
315
+ this.#accessory.addService(Service.Lightbulb, dayName, 'DayNightlight')
316
+ syncServiceNames({ Characteristic, service: dayService, name: dayName })
317
+
318
+ // On (READ/WRITE) - Turn nightlight on/off
319
+ dayService.getCharacteristic(Characteristic.On)
320
+ .onGet(this.getDayNightlightOn.bind(this))
321
+ .onSet(this.setDayNightlightOn.bind(this))
322
+
323
+ // Brightness (READ/WRITE) - Display brightness (screen brightness, not color brightness)
324
+ dayService.getCharacteristic(Characteristic.Brightness)
325
+ .onGet(this.getDayNightlightBrightness.bind(this))
326
+ .onSet(this.setDayNightlightBrightness.bind(this))
327
+
328
+ // Hue (READ/WRITE) - Color hue from ambientColour
329
+ dayService.getCharacteristic(Characteristic.Hue)
330
+ .onGet(this.getDayNightlightHue.bind(this))
331
+ .onSet(this.setDayNightlightHue.bind(this))
332
+
333
+ // Saturation (READ/WRITE) - Color saturation from ambientColour
334
+ dayService.getCharacteristic(Characteristic.Saturation)
335
+ .onGet(this.getDayNightlightSaturation.bind(this))
336
+ .onSet(this.setDayNightlightSaturation.bind(this))
337
+
338
+ this.dayNightlightService = dayService
339
+
340
+ // Night Nightlight
341
+ const nightName = this.generateServiceName('Night Nightlight')
342
+ const nightService = this.#accessory.getServiceById(Service.Lightbulb, 'NightNightlight') ||
343
+ this.#accessory.addService(Service.Lightbulb, nightName, 'NightNightlight')
344
+ syncServiceNames({ Characteristic, service: nightService, name: nightName })
345
+
346
+ // On (READ/WRITE) - Turn nightlight on/off
347
+ nightService.getCharacteristic(Characteristic.On)
348
+ .onGet(this.getNightNightlightOn.bind(this))
349
+ .onSet(this.setNightNightlightOn.bind(this))
350
+
351
+ // Brightness (READ/WRITE) - Display brightness (screen brightness, not color brightness)
352
+ nightService.getCharacteristic(Characteristic.Brightness)
353
+ .onGet(this.getNightNightlightBrightness.bind(this))
354
+ .onSet(this.setNightNightlightBrightness.bind(this))
355
+
356
+ // Hue (READ/WRITE) - Color hue from nightAmbientColour
357
+ nightService.getCharacteristic(Characteristic.Hue)
358
+ .onGet(this.getNightNightlightHue.bind(this))
359
+ .onSet(this.setNightNightlightHue.bind(this))
360
+
361
+ // Saturation (READ/WRITE) - Color saturation from nightAmbientColour
362
+ nightService.getCharacteristic(Characteristic.Saturation)
363
+ .onGet(this.getNightNightlightSaturation.bind(this))
364
+ .onSet(this.setNightNightlightSaturation.bind(this))
365
+
366
+ this.nightNightlightService = nightService
367
+
368
+ // Setup nightlight status ContactSensors
369
+ // These show the live state of nightlights (different from config-based Lightbulb services)
370
+
371
+ // ContactSensor: NightlightActive - Shows if nightlight is currently on
372
+ const nightlightActiveName = this.generateServiceName('Nightlight Active')
373
+ const nightlightActiveService = this.#accessory.getServiceById(Service.ContactSensor, 'NightlightActive') ||
374
+ this.#accessory.addService(Service.ContactSensor, nightlightActiveName, 'NightlightActive')
375
+ syncServiceNames({ Characteristic, service: nightlightActiveService, name: nightlightActiveName })
376
+
377
+ nightlightActiveService.getCharacteristic(Characteristic.ContactSensorState)
378
+ .onGet(this.getNightlightActive.bind(this))
379
+
380
+ this.nightlightActiveService = nightlightActiveService
381
+
382
+ // ContactSensor: DayNightlightActive - Shows if day nightlight is currently showing
383
+ const dayNightlightActiveName = this.generateServiceName('Day Nightlight Active')
384
+ const dayNightlightActiveService = this.#accessory.getServiceById(Service.ContactSensor, 'DayNightlightActive') ||
385
+ this.#accessory.addService(Service.ContactSensor, dayNightlightActiveName, 'DayNightlightActive')
386
+ syncServiceNames({ Characteristic, service: dayNightlightActiveService, name: dayNightlightActiveName })
387
+
388
+ dayNightlightActiveService.getCharacteristic(Characteristic.ContactSensorState)
389
+ .onGet(this.getDayNightlightActive.bind(this))
390
+
391
+ this.dayNightlightActiveService = dayNightlightActiveService
392
+
393
+ // ContactSensor: NightNightlightActive - Shows if night nightlight is currently showing
394
+ const nightNightlightActiveName = this.generateServiceName('Night Nightlight Active')
395
+ const nightNightlightActiveService = this.#accessory.getServiceById(Service.ContactSensor, 'NightNightlightActive') ||
396
+ this.#accessory.addService(Service.ContactSensor, nightNightlightActiveName, 'NightNightlightActive')
397
+ syncServiceNames({ Characteristic, service: nightNightlightActiveService, name: nightNightlightActiveName })
398
+
399
+ nightNightlightActiveService.getCharacteristic(Characteristic.ContactSensorState)
400
+ .onGet(this.getNightNightlightActive.bind(this))
401
+
402
+ this.nightNightlightActiveService = nightNightlightActiveService
403
+
404
+ this.#currentServices.add(dayService)
405
+ this.#currentServices.add(nightService)
406
+ this.#currentServices.add(nightlightActiveService)
407
+ this.#currentServices.add(dayNightlightActiveService)
408
+ this.#currentServices.add(nightNightlightActiveService)
409
+ }
410
+
411
+ /**
412
+ * Setup card slot ContactSensor service
413
+ * Shows if a card is inserted in the player
414
+ */
415
+ setupCardSlotService () {
416
+ const { Service, Characteristic } = this.#platform
417
+ const serviceName = this.generateServiceName('Card Slot')
418
+
419
+ const service = this.#accessory.getServiceById(Service.ContactSensor, 'CardSlot') ||
420
+ this.#accessory.addService(Service.ContactSensor, serviceName, 'CardSlot')
421
+ syncServiceNames({ Characteristic, service, name: serviceName })
422
+
423
+ service.getCharacteristic(Characteristic.ContactSensorState)
424
+ .onGet(this.getCardSlotState.bind(this))
425
+
426
+ this.cardSlotService = service
427
+ this.#currentServices.add(service)
428
+ }
429
+
430
+ /**
431
+ * Setup night mode OccupancySensor service
432
+ * Shows if device is in night mode (vs day mode)
433
+ */
434
+ setupNightModeService () {
435
+ const { Service, Characteristic } = this.#platform
436
+ const serviceName = this.generateServiceName('Night Mode')
437
+
438
+ const service = this.#accessory.getServiceById(Service.OccupancySensor, 'NightModeStatus') ||
439
+ this.#accessory.addService(Service.OccupancySensor, serviceName, 'NightModeStatus')
440
+ syncServiceNames({ Characteristic, service, name: serviceName })
441
+
442
+ service.getCharacteristic(Characteristic.OccupancyDetected)
443
+ .onGet(this.getNightModeStatus.bind(this))
444
+
445
+ this.nightModeService = service
446
+ this.#currentServices.add(service)
447
+ }
448
+
449
+ /**
450
+ * Setup sleep timer Switch service
451
+ * Toggle sleep timer on/off
452
+ */
453
+ setupSleepTimerService () {
454
+ const { Service, Characteristic } = this.#platform
455
+ const serviceName = this.generateServiceName('Sleep Timer')
456
+
457
+ const service = this.#accessory.getServiceById(Service.Switch, 'SleepTimer') ||
458
+ this.#accessory.addService(Service.Switch, serviceName, 'SleepTimer')
459
+ syncServiceNames({ Characteristic, service, name: serviceName })
460
+
461
+ service.getCharacteristic(Characteristic.On)
462
+ .onGet(this.getSleepTimerState.bind(this))
463
+ .onSet(this.setSleepTimerState.bind(this))
464
+
465
+ this.sleepTimerService = service
466
+ this.#currentServices.add(service)
467
+ }
468
+
469
+ /**
470
+ * Setup Bluetooth Switch service
471
+ * Toggle Bluetooth on/off
472
+ */
473
+ setupBluetoothService () {
474
+ const { Service, Characteristic } = this.#platform
475
+ const serviceName = this.generateServiceName('Bluetooth')
476
+
477
+ const service = this.#accessory.getServiceById(Service.Switch, 'Bluetooth') ||
478
+ this.#accessory.addService(Service.Switch, serviceName, 'Bluetooth')
479
+ syncServiceNames({ Characteristic, service, name: serviceName })
480
+
481
+ service.getCharacteristic(Characteristic.On)
482
+ .onGet(this.getBluetoothState.bind(this))
483
+ .onSet(this.setBluetoothState.bind(this))
484
+
485
+ this.bluetoothService = service
486
+ this.#currentServices.add(service)
487
+ }
488
+
489
+ /**
490
+ * Setup volume limit Fanv2 services
491
+ * Control day and night mode max volume limits
492
+ */
493
+ setupVolumeLimitServices () {
494
+ const { Service, Characteristic } = this.#platform
495
+
496
+ // Day Max Volume
497
+ const dayName = this.generateServiceName('Day Max Volume')
498
+ const dayService = this.#accessory.getServiceById(Service.Fanv2, 'DayMaxVolume') ||
499
+ this.#accessory.addService(Service.Fanv2, dayName, 'DayMaxVolume')
500
+ syncServiceNames({ Characteristic, service: dayService, name: dayName })
501
+
502
+ dayService
503
+ .getCharacteristic(Characteristic.Active)
504
+ .onGet(() => Characteristic.Active.ACTIVE)
505
+
506
+ dayService
507
+ .getCharacteristic(Characteristic.RotationSpeed)
508
+ .setProps({ minValue: 0, maxValue: 16, minStep: 1 })
509
+ .onGet(this.getDayMaxVolume.bind(this))
510
+ .onSet(this.setDayMaxVolume.bind(this))
511
+
512
+ this.dayMaxVolumeService = dayService
513
+
514
+ // Night Max Volume
515
+ const nightName = this.generateServiceName('Night Max Volume')
516
+ const nightService = this.#accessory.getServiceById(Service.Fanv2, 'NightMaxVolume') ||
517
+ this.#accessory.addService(Service.Fanv2, nightName, 'NightMaxVolume')
518
+ syncServiceNames({ Characteristic, service: nightService, name: nightName })
519
+
520
+ nightService
521
+ .getCharacteristic(Characteristic.Active)
522
+ .onGet(() => Characteristic.Active.ACTIVE)
523
+
524
+ nightService
525
+ .getCharacteristic(Characteristic.RotationSpeed)
526
+ .setProps({ minValue: 0, maxValue: 16, minStep: 1 })
527
+ .onGet(this.getNightMaxVolume.bind(this))
528
+ .onSet(this.setNightMaxVolume.bind(this))
529
+
530
+ this.nightMaxVolumeService = nightService
531
+
532
+ this.#currentServices.add(dayService)
533
+ this.#currentServices.add(nightService)
534
+ }
535
+
536
+ /**
537
+ * Setup event listeners for device model updates
538
+ * Uses exhaustive switch pattern for type safety
539
+ */
540
+ setupEventListeners () {
541
+ // Status updates - exhaustive field checking
542
+ this.#deviceModel.on('statusUpdate', (status, _source, changedFields) => {
543
+ for (const field of changedFields) {
544
+ switch (field) {
545
+ case 'volume':
546
+ this.updateVolumeCharacteristic(status.volume)
547
+ this.updateMuteCharacteristic(status.volume)
548
+ break
549
+
550
+ case 'batteryLevelPercentage':
551
+ this.updateBatteryLevelCharacteristic(status.batteryLevelPercentage)
552
+ this.updateLowBatteryCharacteristic(status.batteryLevelPercentage)
553
+ break
554
+
555
+ case 'isCharging':
556
+ this.updateChargingStateCharacteristic(status.isCharging)
557
+ break
558
+
559
+ case 'isOnline':
560
+ this.updateOnlineStatusCharacteristic(status.isOnline)
561
+ break
562
+
563
+ case 'firmwareVersion':
564
+ this.updateFirmwareVersionCharacteristic(status.firmwareVersion)
565
+ break
566
+
567
+ case 'maxVolume':
568
+ this.updateVolumeLimitProps(status.maxVolume)
569
+ break
570
+
571
+ case 'temperatureCelsius':
572
+ if (this.#deviceModel.capabilities.hasTemperatureSensor && status.temperatureCelsius !== null) {
573
+ this.updateTemperatureCharacteristic(status.temperatureCelsius)
574
+ }
575
+ break
576
+
577
+ case 'nightlightMode':
578
+ // Update nightlight status ContactSensors
579
+ if (this.#deviceModel.capabilities.hasColoredNightlight) {
580
+ this.updateNightlightStatusCharacteristics()
581
+ }
582
+ break
583
+
584
+ case 'dayMode':
585
+ // Update night mode OccupancySensor
586
+ this.updateNightModeCharacteristic()
587
+ // Update nightlight status ContactSensors (depends on dayMode)
588
+ if (this.#deviceModel.capabilities.hasColoredNightlight) {
589
+ this.updateNightlightStatusCharacteristics()
590
+ }
591
+ // Day/night mode affects which volume limit is active
592
+ this.updateVolumeLimitProps(status.maxVolume)
593
+ break
594
+
595
+ case 'cardInsertionState':
596
+ this.updateCardSlotCharacteristic()
597
+ break
598
+
599
+ // Available but not yet mapped to characteristics
600
+ case 'activeCardId':
601
+ case 'powerSource':
602
+ case 'wifiStrength':
603
+ case 'freeDiskSpaceBytes':
604
+ case 'totalDiskSpaceBytes':
605
+ case 'isAudioDeviceConnected':
606
+ case 'isBluetoothAudioConnected':
607
+ case 'ambientLightSensorReading':
608
+ case 'displayBrightness':
609
+ case 'timeFormat':
610
+ case 'uptime':
611
+ case 'updatedAt':
612
+ case 'source':
613
+ // Not implemented - empty case documents availability
614
+ break
615
+
616
+ default: {
617
+ // Exhaustive check - TypeScript will error if a field is missed
618
+ /** @type {never} */
619
+ const _exhaustive = field
620
+ this.#log.debug('Unhandled status field:', _exhaustive)
621
+ break
622
+ }
623
+ }
624
+ }
625
+ })
626
+
627
+ // Config updates - exhaustive field checking
628
+ this.#deviceModel.on('configUpdate', (config, changedFields) => {
629
+ const { Characteristic } = this.#platform
630
+
631
+ for (const field of changedFields) {
632
+ switch (field) {
633
+ case 'dayDisplayBrightness':
634
+ case 'dayDisplayBrightnessAuto': {
635
+ if (this.dayNightlightService) {
636
+ const raw = config.dayDisplayBrightnessAuto ? 100 : (config.dayDisplayBrightness ?? 100)
637
+ const brightness = Math.max(0, Math.min(Math.round(raw), 100))
638
+ this.dayNightlightService.updateCharacteristic(Characteristic.Brightness, brightness)
639
+ }
640
+ break
641
+ }
642
+
643
+ case 'nightDisplayBrightness':
644
+ case 'nightDisplayBrightnessAuto': {
645
+ if (this.nightNightlightService) {
646
+ const raw = config.nightDisplayBrightnessAuto ? 100 : (config.nightDisplayBrightness ?? 100)
647
+ const brightness = Math.max(0, Math.min(Math.round(raw), 100))
648
+ this.nightNightlightService.updateCharacteristic(Characteristic.Brightness, brightness)
649
+ }
650
+ break
651
+ }
652
+
653
+ case 'ambientColour': {
654
+ if (this.dayNightlightService) {
655
+ const isOn = !this.isColorOff(config.ambientColour)
656
+ this.dayNightlightService.updateCharacteristic(Characteristic.On, isOn)
657
+
658
+ if (isOn) {
659
+ const hex = this.parseHexColor(config.ambientColour)
660
+ const [h, s] = convert.hex.hsv(hex)
661
+ this.dayNightlightService.updateCharacteristic(Characteristic.Hue, h)
662
+ this.dayNightlightService.updateCharacteristic(Characteristic.Saturation, s)
663
+ }
664
+ }
665
+ break
666
+ }
667
+
668
+ case 'nightAmbientColour': {
669
+ if (this.nightNightlightService) {
670
+ const isOn = !this.isColorOff(config.nightAmbientColour)
671
+ this.nightNightlightService.updateCharacteristic(Characteristic.On, isOn)
672
+
673
+ if (isOn) {
674
+ const hex = this.parseHexColor(config.nightAmbientColour)
675
+ const [h, s] = convert.hex.hsv(hex)
676
+ this.nightNightlightService.updateCharacteristic(Characteristic.Hue, h)
677
+ this.nightNightlightService.updateCharacteristic(Characteristic.Saturation, s)
678
+ }
679
+ }
680
+ break
681
+ }
682
+
683
+ case 'maxVolumeLimit':
684
+ this.updateVolumeLimitCharacteristics()
685
+ break
686
+
687
+ case 'nightMaxVolumeLimit':
688
+ this.updateVolumeLimitCharacteristics()
689
+ break
690
+
691
+ case 'bluetoothEnabled':
692
+ this.updateBluetoothCharacteristic()
693
+ break
694
+
695
+ // Config fields available but not exposed as characteristics yet
696
+ case 'alarms':
697
+ case 'btHeadphonesEnabled':
698
+ case 'clockFace':
699
+ case 'dayTime':
700
+ case 'nightTime':
701
+ case 'dayYotoDaily':
702
+ case 'nightYotoDaily':
703
+ case 'dayYotoRadio':
704
+ case 'nightYotoRadio':
705
+ case 'nightYotoRadioEnabled':
706
+ case 'daySoundsOff':
707
+ case 'nightSoundsOff':
708
+ case 'displayDimBrightness':
709
+ case 'displayDimTimeout':
710
+ case 'headphonesVolumeLimited':
711
+ case 'hourFormat':
712
+ case 'locale':
713
+ case 'logLevel':
714
+ case 'pausePowerButton':
715
+ case 'pauseVolumeDown':
716
+ case 'repeatAll':
717
+ case 'showDiagnostics':
718
+ case 'shutdownTimeout':
719
+ case 'systemVolume':
720
+ case 'timezone':
721
+ case 'volumeLevel': {
722
+ // Not exposed - empty case documents availability
723
+ break
724
+ }
725
+
726
+ default: {
727
+ /** @type {never} */
728
+ const _exhaustive = field
729
+ this.#log.debug('Unhandled config field:', _exhaustive)
730
+ break
731
+ }
732
+ }
733
+ }
734
+ })
735
+
736
+ // Playback updates - exhaustive field checking
737
+ this.#deviceModel.on('playbackUpdate', (playback, changedFields) => {
738
+ for (const field of changedFields) {
739
+ switch (field) {
740
+ case 'playbackStatus':
741
+ this.updatePlaybackSwitchCharacteristic(playback.playbackStatus)
742
+ break
743
+
744
+ case 'position':
745
+ case 'trackLength':
746
+ break
747
+
748
+ case 'sleepTimerActive':
749
+ this.updateSleepTimerCharacteristic()
750
+ break
751
+
752
+ // Playback fields - informational only
753
+ case 'cardId':
754
+ case 'source':
755
+ case 'trackTitle':
756
+ case 'trackKey':
757
+ case 'chapterTitle':
758
+ case 'chapterKey':
759
+ case 'sleepTimerSeconds':
760
+ case 'streaming':
761
+ case 'updatedAt': {
762
+ // Not exposed as characteristics
763
+ break
764
+ }
765
+
766
+ default: {
767
+ /** @type {never} */
768
+ const _exhaustive = field
769
+ this.#log.debug('Unhandled playback field:', _exhaustive)
770
+ break
771
+ }
772
+ }
773
+ }
774
+ })
775
+
776
+ // Lifecycle events
777
+ this.#deviceModel.on('online', ({ reason }) => {
778
+ this.#log.info(`[${this.#device.name}] Device came online (${reason})`)
779
+ this.updateOnlineStatusCharacteristic(true)
780
+ })
781
+
782
+ this.#deviceModel.on('offline', ({ reason }) => {
783
+ this.#log.warn(`[${this.#device.name}] Device went offline (${reason})`)
784
+ this.updateOnlineStatusCharacteristic(false)
785
+ })
786
+
787
+ this.#deviceModel.on('error', (error) => {
788
+ this.#log.error(`[${this.#device.name}] Device error:`, error.message)
789
+ })
790
+ }
791
+
792
+ // ==================== Playback (Switch) Characteristic Handlers ====================
793
+
794
+ /**
795
+ * Get play/pause state as a Switch "On" value
796
+ * @returns {Promise<CharacteristicValue>}
797
+ */
798
+ async getPlaybackOn () {
799
+ return this.#deviceModel.playback.playbackStatus === 'playing'
800
+ }
801
+
802
+ /**
803
+ * Set play/pause state via Switch "On"
804
+ * @param {CharacteristicValue} value
805
+ * @returns {Promise<void>}
806
+ */
807
+ async setPlaybackOn (value) {
808
+ const isOn = Boolean(value)
809
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Set playback switch:`, isOn)
810
+
811
+ try {
812
+ if (isOn) {
813
+ await this.#deviceModel.resumeCard()
814
+ } else {
815
+ await this.#deviceModel.pauseCard()
816
+ }
817
+ } catch (error) {
818
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set playback state:`, error)
819
+ throw new this.#platform.api.hap.HapStatusError(
820
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
821
+ )
822
+ }
823
+ }
824
+
825
+ /**
826
+ * Get volume level (0-16 steps) from live status
827
+ * @returns {Promise<CharacteristicValue>}
828
+ */
829
+ async getVolume () {
830
+ return this.#deviceModel.status.volume
831
+ }
832
+
833
+ /**
834
+ * Set volume level (0-16 steps)
835
+ * @param {CharacteristicValue} value
836
+ * @returns {Promise<void>}
837
+ */
838
+ async setVolume (value) {
839
+ const deviceModel = this.#deviceModel
840
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Set volume:`, value)
841
+
842
+ const requestedSteps = typeof value === 'number' ? value : Number(value)
843
+ if (!Number.isFinite(requestedSteps)) {
844
+ throw new this.#platform.api.hap.HapStatusError(
845
+ this.#platform.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST
846
+ )
847
+ }
848
+
849
+ const maxVolumeSteps = Number.isFinite(deviceModel.status.maxVolume)
850
+ ? deviceModel.status.maxVolume
851
+ : 16
852
+ const steps = Math.max(0, Math.min(Math.round(requestedSteps), maxVolumeSteps))
853
+
854
+ // Track last non-zero volume for unmute
855
+ if (steps > 0) {
856
+ this.#lastNonZeroVolume = steps
857
+ }
858
+
859
+ try {
860
+ await deviceModel.setVolume(steps)
861
+ if (this.volumeService) {
862
+ const { Characteristic } = this.#platform
863
+
864
+ const active = steps === 0
865
+ ? Characteristic.Active.INACTIVE
866
+ : Characteristic.Active.ACTIVE
867
+
868
+ this.volumeService
869
+ .getCharacteristic(Characteristic.Active)
870
+ .updateValue(active)
871
+
872
+ if (steps !== requestedSteps) {
873
+ this.volumeService
874
+ .getCharacteristic(Characteristic.RotationSpeed)
875
+ .updateValue(steps)
876
+ }
877
+ }
878
+ } catch (error) {
879
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set volume:`, error)
880
+ throw new this.#platform.api.hap.HapStatusError(
881
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
882
+ )
883
+ }
884
+ }
885
+
886
+ /**
887
+ * Get volume Active state (derived from volume === 0)
888
+ * @returns {Promise<CharacteristicValue>}
889
+ */
890
+ async getVolumeActive () {
891
+ const { Characteristic } = this.#platform
892
+ return this.#deviceModel.status.volume === 0
893
+ ? Characteristic.Active.INACTIVE
894
+ : Characteristic.Active.ACTIVE
895
+ }
896
+
897
+ /**
898
+ * Set volume Active state (mute/unmute)
899
+ * @param {CharacteristicValue} value
900
+ * @returns {Promise<void>}
901
+ */
902
+ async setVolumeActive (value) {
903
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Set volume active:`, value)
904
+
905
+ const { Characteristic } = this.#platform
906
+ const active = typeof value === 'number' ? value : Number(value)
907
+ if (!Number.isFinite(active)) {
908
+ throw new this.#platform.api.hap.HapStatusError(
909
+ this.#platform.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST
910
+ )
911
+ }
912
+
913
+ if (active === Characteristic.Active.INACTIVE) {
914
+ await this.setVolume(0)
915
+ return
916
+ }
917
+
918
+ if (active === Characteristic.Active.ACTIVE) {
919
+ await this.setVolume(this.#lastNonZeroVolume)
920
+ return
921
+ }
922
+
923
+ throw new this.#platform.api.hap.HapStatusError(
924
+ this.#platform.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST
925
+ )
926
+ }
927
+
928
+ /**
929
+ * Get status active (online/offline)
930
+ * @returns {Promise<CharacteristicValue>}
931
+ */
932
+ async getStatusActive () {
933
+ return this.#deviceModel.status.isOnline
934
+ }
935
+
936
+ // ==================== Battery Characteristic Handlers ====================
937
+
938
+ /**
939
+ * Get battery level (0-100) from live status
940
+ * @returns {Promise<CharacteristicValue>}
941
+ */
942
+ async getBatteryLevel () {
943
+ return this.#deviceModel.status.batteryLevelPercentage || 100
944
+ }
945
+
946
+ /**
947
+ * Get charging state from live status
948
+ * @returns {Promise<CharacteristicValue>}
949
+ */
950
+ async getChargingState () {
951
+ const isCharging = this.#deviceModel.status.isCharging
952
+ return isCharging
953
+ ? this.#platform.Characteristic.ChargingState.CHARGING
954
+ : this.#platform.Characteristic.ChargingState.NOT_CHARGING
955
+ }
956
+
957
+ /**
958
+ * Get low battery status from live status
959
+ * @returns {Promise<CharacteristicValue>}
960
+ */
961
+ async getStatusLowBattery () {
962
+ const battery = this.#deviceModel.status.batteryLevelPercentage || 100
963
+ return battery <= LOW_BATTERY_THRESHOLD
964
+ ? this.#platform.Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW
965
+ : this.#platform.Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL
966
+ }
967
+
968
+ // ==================== TemperatureSensor Characteristic Handlers ====================
969
+
970
+ /**
971
+ * Get current temperature from live status
972
+ * @returns {Promise<CharacteristicValue>}
973
+ */
974
+ async getCurrentTemperature () {
975
+ const temp = this.#deviceModel.status.temperatureCelsius
976
+
977
+ // Return a default value if temperature is not available
978
+ if (temp === null || temp === 'notSupported') {
979
+ return 0
980
+ }
981
+
982
+ return Number(temp)
983
+ }
984
+
985
+ /**
986
+ * Get temperature sensor fault status
987
+ * @returns {Promise<CharacteristicValue>}
988
+ */
989
+ async getTemperatureSensorFault () {
990
+ // Report fault if device is offline or temperature is not available
991
+ if (!this.#deviceModel.status.isOnline ||
992
+ this.#deviceModel.status.temperatureCelsius === null ||
993
+ this.#deviceModel.status.temperatureCelsius === 'notSupported') {
994
+ return this.#platform.Characteristic.StatusFault.GENERAL_FAULT
995
+ }
996
+
997
+ return this.#platform.Characteristic.StatusFault.NO_FAULT
998
+ }
999
+
1000
+ // ==================== Nightlight Characteristic Handlers ====================
1001
+
1002
+ /**
1003
+ * Helper: Parse hex color (handles both '0xRRGGBB' and '#RRGGBB' formats)
1004
+ * @param {string} hexColor - Hex color string
1005
+ * @returns {string} - Normalized hex without prefix (RRGGBB)
1006
+ */
1007
+ parseHexColor (hexColor) {
1008
+ if (!hexColor) return '000000'
1009
+ // Remove '0x' or '#' prefix
1010
+ return hexColor.replace(/^(0x|#)/, '')
1011
+ }
1012
+
1013
+ /**
1014
+ * Helper: Format hex color to Yoto format (0xRRGGBB)
1015
+ * @param {string} hex - Hex color without prefix (RRGGBB)
1016
+ * @returns {string} - Formatted as '0xRRGGBB'
1017
+ */
1018
+ formatHexColor (hex) {
1019
+ return `0x${hex}`
1020
+ }
1021
+
1022
+ /**
1023
+ * Helper: Check if color is "off" (black or 'off' string)
1024
+ * @param {string} color - Color value
1025
+ * @returns {boolean}
1026
+ */
1027
+ isColorOff (color) {
1028
+ return !color || color === 'off' || color === '0x000000' || color === '#000000' || color === '000000'
1029
+ }
1030
+
1031
+ // ---------- Day Nightlight Handlers ----------
1032
+
1033
+ /**
1034
+ * Get day nightlight on/off state
1035
+ * @returns {Promise<CharacteristicValue>}
1036
+ */
1037
+ async getDayNightlightOn () {
1038
+ const color = this.#deviceModel.config.ambientColour
1039
+ return !this.isColorOff(color)
1040
+ }
1041
+
1042
+ /**
1043
+ * Set day nightlight on/off state
1044
+ * @param {CharacteristicValue} value
1045
+ */
1046
+ async setDayNightlightOn (value) {
1047
+ if (value) {
1048
+ // Turn ON - restore previous color or default to white
1049
+ const colorToSet = this.#lastDayColor || '0xffffff'
1050
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Turning day nightlight ON with color: ${colorToSet}`)
1051
+ await this.#deviceModel.updateConfig({ ambientColour: colorToSet })
1052
+ } else {
1053
+ // Turn OFF - save current color and set to black
1054
+ const currentColor = this.#deviceModel.config.ambientColour
1055
+ if (!this.isColorOff(currentColor)) {
1056
+ this.#lastDayColor = currentColor
1057
+ }
1058
+ this.#log.debug(LOG_PREFIX.ACCESSORY, 'Turning day nightlight OFF')
1059
+ await this.#deviceModel.updateConfig({ ambientColour: '0x000000' })
1060
+ }
1061
+ }
1062
+
1063
+ /**
1064
+ * Get day nightlight brightness (screen brightness)
1065
+ * @returns {Promise<CharacteristicValue>}
1066
+ */
1067
+ async getDayNightlightBrightness () {
1068
+ const config = this.#deviceModel.config
1069
+ if (config.dayDisplayBrightnessAuto || config.dayDisplayBrightness === null) {
1070
+ return 100
1071
+ }
1072
+ return Math.max(0, Math.min(Math.round(config.dayDisplayBrightness), 100))
1073
+ }
1074
+
1075
+ /**
1076
+ * Set day nightlight brightness (screen brightness)
1077
+ * @param {CharacteristicValue} value
1078
+ */
1079
+ async setDayNightlightBrightness (value) {
1080
+ const brightnessValue = Math.round(Number(value))
1081
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Setting day display brightness: ${brightnessValue}`)
1082
+ await this.#deviceModel.updateConfig({
1083
+ dayDisplayBrightness: brightnessValue,
1084
+ dayDisplayBrightnessAuto: false
1085
+ })
1086
+ }
1087
+
1088
+ /**
1089
+ * Get day nightlight hue
1090
+ * @returns {Promise<CharacteristicValue>}
1091
+ */
1092
+ async getDayNightlightHue () {
1093
+ const color = this.#deviceModel.config.ambientColour
1094
+ if (this.isColorOff(color)) {
1095
+ return 0
1096
+ }
1097
+ const hex = this.parseHexColor(color)
1098
+ const [h] = convert.hex.hsv(hex)
1099
+ return h
1100
+ }
1101
+
1102
+ /**
1103
+ * Set day nightlight hue
1104
+ * @param {CharacteristicValue} value
1105
+ */
1106
+ async setDayNightlightHue (value) {
1107
+ const hue = Number(value)
1108
+
1109
+ // Get current saturation to maintain it
1110
+ const currentColor = this.#deviceModel.config.ambientColour
1111
+ let saturation = 100
1112
+ if (!this.isColorOff(currentColor)) {
1113
+ const hex = this.parseHexColor(currentColor)
1114
+ const [, s] = convert.hex.hsv(hex)
1115
+ saturation = s
1116
+ }
1117
+
1118
+ // Convert HSV to hex (use full value/brightness for color)
1119
+ const newHex = convert.hsv.hex([hue, saturation, 100])
1120
+ const formattedColor = this.formatHexColor(newHex)
1121
+
1122
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Setting day nightlight hue: ${hue}° → ${formattedColor}`)
1123
+ await this.#deviceModel.updateConfig({ ambientColour: formattedColor })
1124
+ }
1125
+
1126
+ /**
1127
+ * Get day nightlight saturation
1128
+ * @returns {Promise<CharacteristicValue>}
1129
+ */
1130
+ async getDayNightlightSaturation () {
1131
+ const color = this.#deviceModel.config.ambientColour
1132
+ if (this.isColorOff(color)) {
1133
+ return 0
1134
+ }
1135
+ const hex = this.parseHexColor(color)
1136
+ const [, s] = convert.hex.hsv(hex)
1137
+ return s
1138
+ }
1139
+
1140
+ /**
1141
+ * Set day nightlight saturation
1142
+ * @param {CharacteristicValue} value
1143
+ */
1144
+ async setDayNightlightSaturation (value) {
1145
+ const saturation = Number(value)
1146
+
1147
+ // Get current hue to maintain it
1148
+ const currentColor = this.#deviceModel.config.ambientColour
1149
+ let hue = 0
1150
+ if (!this.isColorOff(currentColor)) {
1151
+ const hex = this.parseHexColor(currentColor)
1152
+ const [h] = convert.hex.hsv(hex)
1153
+ hue = h
1154
+ }
1155
+
1156
+ // Convert HSV to hex (use full value/brightness for color)
1157
+ const newHex = convert.hsv.hex([hue, saturation, 100])
1158
+ const formattedColor = this.formatHexColor(newHex)
1159
+
1160
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Setting day nightlight saturation: ${saturation}% → ${formattedColor}`)
1161
+ await this.#deviceModel.updateConfig({ ambientColour: formattedColor })
1162
+ }
1163
+
1164
+ // ---------- Night Nightlight Handlers ----------
1165
+
1166
+ /**
1167
+ * Get night nightlight on/off state
1168
+ * @returns {Promise<CharacteristicValue>}
1169
+ */
1170
+ async getNightNightlightOn () {
1171
+ const color = this.#deviceModel.config.nightAmbientColour
1172
+ return !this.isColorOff(color)
1173
+ }
1174
+
1175
+ /**
1176
+ * Set night nightlight on/off state
1177
+ * @param {CharacteristicValue} value
1178
+ */
1179
+ async setNightNightlightOn (value) {
1180
+ if (value) {
1181
+ // Turn ON - restore previous color or default to white
1182
+ const colorToSet = this.#lastNightColor || '0xffffff'
1183
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Turning night nightlight ON with color: ${colorToSet}`)
1184
+ await this.#deviceModel.updateConfig({ nightAmbientColour: colorToSet })
1185
+ } else {
1186
+ // Turn OFF - save current color and set to black
1187
+ const currentColor = this.#deviceModel.config.nightAmbientColour
1188
+ if (!this.isColorOff(currentColor)) {
1189
+ this.#lastNightColor = currentColor
1190
+ }
1191
+ this.#log.debug(LOG_PREFIX.ACCESSORY, 'Turning night nightlight OFF')
1192
+ await this.#deviceModel.updateConfig({ nightAmbientColour: '0x000000' })
1193
+ }
1194
+ }
1195
+
1196
+ /**
1197
+ * Get night nightlight brightness (screen brightness)
1198
+ * @returns {Promise<CharacteristicValue>}
1199
+ */
1200
+ async getNightNightlightBrightness () {
1201
+ const config = this.#deviceModel.config
1202
+ if (config.nightDisplayBrightnessAuto || config.nightDisplayBrightness === null) {
1203
+ return 100
1204
+ }
1205
+ return Math.max(0, Math.min(Math.round(config.nightDisplayBrightness), 100))
1206
+ }
1207
+
1208
+ /**
1209
+ * Set night nightlight brightness (screen brightness)
1210
+ * @param {CharacteristicValue} value
1211
+ */
1212
+ async setNightNightlightBrightness (value) {
1213
+ const brightnessValue = Math.round(Number(value))
1214
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Setting night display brightness: ${brightnessValue}`)
1215
+ await this.#deviceModel.updateConfig({
1216
+ nightDisplayBrightness: brightnessValue,
1217
+ nightDisplayBrightnessAuto: false
1218
+ })
1219
+ }
1220
+
1221
+ /**
1222
+ * Get night nightlight hue
1223
+ * @returns {Promise<CharacteristicValue>}
1224
+ */
1225
+ async getNightNightlightHue () {
1226
+ const color = this.#deviceModel.config.nightAmbientColour
1227
+ if (this.isColorOff(color)) {
1228
+ return 0
1229
+ }
1230
+ const hex = this.parseHexColor(color)
1231
+ const [h] = convert.hex.hsv(hex)
1232
+ return h
1233
+ }
1234
+
1235
+ /**
1236
+ * Set night nightlight hue
1237
+ * @param {CharacteristicValue} value
1238
+ */
1239
+ async setNightNightlightHue (value) {
1240
+ const hue = Number(value)
1241
+
1242
+ // Get current saturation to maintain it
1243
+ const currentColor = this.#deviceModel.config.nightAmbientColour
1244
+ let saturation = 100
1245
+ if (!this.isColorOff(currentColor)) {
1246
+ const hex = this.parseHexColor(currentColor)
1247
+ const [, s] = convert.hex.hsv(hex)
1248
+ saturation = s
1249
+ }
1250
+
1251
+ // Convert HSV to hex (use full value/brightness for color)
1252
+ const newHex = convert.hsv.hex([hue, saturation, 100])
1253
+ const formattedColor = this.formatHexColor(newHex)
1254
+
1255
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Setting night nightlight hue: ${hue}° → ${formattedColor}`)
1256
+ await this.#deviceModel.updateConfig({ nightAmbientColour: formattedColor })
1257
+ }
1258
+
1259
+ /**
1260
+ * Get night nightlight saturation
1261
+ * @returns {Promise<CharacteristicValue>}
1262
+ */
1263
+ async getNightNightlightSaturation () {
1264
+ const color = this.#deviceModel.config.nightAmbientColour
1265
+ if (this.isColorOff(color)) {
1266
+ return 0
1267
+ }
1268
+ const hex = this.parseHexColor(color)
1269
+ const [, s] = convert.hex.hsv(hex)
1270
+ return s
1271
+ }
1272
+
1273
+ /**
1274
+ * Set night nightlight saturation
1275
+ * @param {CharacteristicValue} value
1276
+ */
1277
+ async setNightNightlightSaturation (value) {
1278
+ const saturation = Number(value)
1279
+
1280
+ // Get current hue to maintain it
1281
+ const currentColor = this.#deviceModel.config.nightAmbientColour
1282
+ let hue = 0
1283
+ if (!this.isColorOff(currentColor)) {
1284
+ const hex = this.parseHexColor(currentColor)
1285
+ const [h] = convert.hex.hsv(hex)
1286
+ hue = h
1287
+ }
1288
+
1289
+ // Convert HSV to hex (use full value/brightness for color)
1290
+ const newHex = convert.hsv.hex([hue, saturation, 100])
1291
+ const formattedColor = this.formatHexColor(newHex)
1292
+
1293
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Setting night nightlight saturation: ${saturation}% → ${formattedColor}`)
1294
+ await this.#deviceModel.updateConfig({ nightAmbientColour: formattedColor })
1295
+ }
1296
+
1297
+ // ==================== Nightlight Status ContactSensor Getters ====================
1298
+
1299
+ /**
1300
+ * Get nightlight active state
1301
+ * @returns {Promise<CharacteristicValue>}
1302
+ */
1303
+ async getNightlightActive () {
1304
+ const { Characteristic } = this.#platform
1305
+ const status = this.#deviceModel.status
1306
+ const isActive = status.nightlightMode !== 'off'
1307
+ return isActive ? Characteristic.ContactSensorState.CONTACT_DETECTED : Characteristic.ContactSensorState.CONTACT_NOT_DETECTED
1308
+ }
1309
+
1310
+ /**
1311
+ * Get day nightlight active state
1312
+ * @returns {Promise<CharacteristicValue>}
1313
+ */
1314
+ async getDayNightlightActive () {
1315
+ const { Characteristic } = this.#platform
1316
+ const status = this.#deviceModel.status
1317
+ const isDay = status.dayMode === 'day'
1318
+ const isActive = status.nightlightMode !== 'off'
1319
+ const isShowing = isDay && isActive
1320
+ return isShowing ? Characteristic.ContactSensorState.CONTACT_DETECTED : Characteristic.ContactSensorState.CONTACT_NOT_DETECTED
1321
+ }
1322
+
1323
+ /**
1324
+ * Get night nightlight active state
1325
+ * @returns {Promise<CharacteristicValue>}
1326
+ */
1327
+ async getNightNightlightActive () {
1328
+ const { Characteristic } = this.#platform
1329
+ const status = this.#deviceModel.status
1330
+ const isNight = status.dayMode === 'night'
1331
+ const isActive = status.nightlightMode !== 'off'
1332
+ const isShowing = isNight && isActive
1333
+ return isShowing ? Characteristic.ContactSensorState.CONTACT_DETECTED : Characteristic.ContactSensorState.CONTACT_NOT_DETECTED
1334
+ }
1335
+
1336
+ // ==================== Card Slot ContactSensor Getter ====================
1337
+
1338
+ /**
1339
+ * Get card slot state
1340
+ * @returns {Promise<CharacteristicValue>}
1341
+ */
1342
+ async getCardSlotState () {
1343
+ const { Characteristic } = this.#platform
1344
+ const status = this.#deviceModel.status
1345
+ const hasCard = status.cardInsertionState !== 'none'
1346
+ return hasCard ? Characteristic.ContactSensorState.CONTACT_DETECTED : Characteristic.ContactSensorState.CONTACT_NOT_DETECTED
1347
+ }
1348
+
1349
+ // ==================== Night Mode OccupancySensor Getter ====================
1350
+
1351
+ /**
1352
+ * Get night mode status
1353
+ * @returns {Promise<CharacteristicValue>}
1354
+ */
1355
+ async getNightModeStatus () {
1356
+ const { Characteristic } = this.#platform
1357
+ const status = this.#deviceModel.status
1358
+ const isNightMode = status.dayMode === 'night'
1359
+ return isNightMode ? Characteristic.OccupancyDetected.OCCUPANCY_DETECTED : Characteristic.OccupancyDetected.OCCUPANCY_NOT_DETECTED
1360
+ }
1361
+
1362
+ // ==================== Sleep Timer Switch Getter/Setter ====================
1363
+
1364
+ /**
1365
+ * Get sleep timer state
1366
+ * @returns {Promise<CharacteristicValue>}
1367
+ */
1368
+ async getSleepTimerState () {
1369
+ const playback = this.#deviceModel.playback
1370
+ return playback.sleepTimerActive ?? false
1371
+ }
1372
+
1373
+ /**
1374
+ * Set sleep timer state
1375
+ * @param {CharacteristicValue} value
1376
+ */
1377
+ async setSleepTimerState (value) {
1378
+ const enabled = Boolean(value)
1379
+
1380
+ try {
1381
+ if (enabled) {
1382
+ // Turn on sleep timer - default to 30 minutes
1383
+ this.#log.info(LOG_PREFIX.ACCESSORY, 'Activating sleep timer (30 minutes)')
1384
+ await this.#deviceModel.setSleepTimer(30 * 60)
1385
+ } else {
1386
+ // Turn off sleep timer
1387
+ this.#log.info(LOG_PREFIX.ACCESSORY, 'Deactivating sleep timer')
1388
+ await this.#deviceModel.setSleepTimer(0)
1389
+ }
1390
+ } catch (error) {
1391
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set sleep timer:`, error)
1392
+ throw new this.#platform.api.hap.HapStatusError(
1393
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1394
+ )
1395
+ }
1396
+ }
1397
+
1398
+ // ==================== Bluetooth Switch Getter/Setter ====================
1399
+
1400
+ /**
1401
+ * Get Bluetooth state
1402
+ * @returns {Promise<CharacteristicValue>}
1403
+ */
1404
+ async getBluetoothState () {
1405
+ return this.#deviceModel.config.bluetoothEnabled
1406
+ }
1407
+
1408
+ /**
1409
+ * Set Bluetooth state
1410
+ * @param {CharacteristicValue} value
1411
+ */
1412
+ async setBluetoothState (value) {
1413
+ const enabled = Boolean(value)
1414
+ this.#log.info(LOG_PREFIX.ACCESSORY, `Setting Bluetooth: ${enabled ? 'ON' : 'OFF'}`)
1415
+ await this.#deviceModel.updateConfig({ bluetoothEnabled: enabled })
1416
+ }
1417
+
1418
+ // ==================== Volume Limit Fanv2 Getters/Setters ====================
1419
+
1420
+ /**
1421
+ * Get day max volume limit
1422
+ * @returns {Promise<CharacteristicValue>}
1423
+ */
1424
+ async getDayMaxVolume () {
1425
+ const limit = this.#deviceModel.config.maxVolumeLimit
1426
+ return Number.isFinite(limit) ? limit : 16
1427
+ }
1428
+
1429
+ /**
1430
+ * Set day max volume limit
1431
+ * @param {CharacteristicValue} value
1432
+ */
1433
+ async setDayMaxVolume (value) {
1434
+ const requested = typeof value === 'number' ? value : Number(value)
1435
+ if (!Number.isFinite(requested)) {
1436
+ throw new this.#platform.api.hap.HapStatusError(
1437
+ this.#platform.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST
1438
+ )
1439
+ }
1440
+
1441
+ const limit = Math.max(0, Math.min(Math.round(requested), 16))
1442
+ this.#log.info(LOG_PREFIX.ACCESSORY, `Setting day max volume limit: ${limit}/16`)
1443
+ await this.#deviceModel.updateConfig({ maxVolumeLimit: limit })
1444
+ }
1445
+
1446
+ /**
1447
+ * Get night max volume limit
1448
+ * @returns {Promise<CharacteristicValue>}
1449
+ */
1450
+ async getNightMaxVolume () {
1451
+ const limit = this.#deviceModel.config.nightMaxVolumeLimit
1452
+ return Number.isFinite(limit) ? limit : 10
1453
+ }
1454
+
1455
+ /**
1456
+ * Set night max volume limit
1457
+ * @param {CharacteristicValue} value
1458
+ */
1459
+ async setNightMaxVolume (value) {
1460
+ const requested = typeof value === 'number' ? value : Number(value)
1461
+ if (!Number.isFinite(requested)) {
1462
+ throw new this.#platform.api.hap.HapStatusError(
1463
+ this.#platform.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST
1464
+ )
1465
+ }
1466
+
1467
+ const limit = Math.max(0, Math.min(Math.round(requested), 16))
1468
+ this.#log.info(LOG_PREFIX.ACCESSORY, `Setting night max volume limit: ${limit}/16`)
1469
+ await this.#deviceModel.updateConfig({ nightMaxVolumeLimit: limit })
1470
+ }
1471
+
1472
+ // ==================== Characteristic Update Methods ====================
1473
+
1474
+ /**
1475
+ * Update playback switch characteristic
1476
+ * @param { "playing" | "paused" | "stopped" | "loading" | null} playbackStatus - Playback status
1477
+ */
1478
+ updatePlaybackSwitchCharacteristic (playbackStatus) {
1479
+ if (!this.playbackService) return
1480
+
1481
+ const { Characteristic } = this.#platform
1482
+ const isOn = playbackStatus === 'playing'
1483
+ this.playbackService
1484
+ .getCharacteristic(Characteristic.On)
1485
+ .updateValue(isOn)
1486
+ }
1487
+
1488
+ /**
1489
+ * Update volume characteristic
1490
+ * @param {number} volumeSteps - Volume level (0-16)
1491
+ */
1492
+ updateVolumeCharacteristic (volumeSteps) {
1493
+ if (!this.volumeService) return
1494
+
1495
+ const { Characteristic } = this.#platform
1496
+ if (volumeSteps > 0) {
1497
+ this.#lastNonZeroVolume = volumeSteps
1498
+ }
1499
+
1500
+ this.volumeService
1501
+ .getCharacteristic(Characteristic.RotationSpeed)
1502
+ .updateValue(volumeSteps)
1503
+ }
1504
+
1505
+ /**
1506
+ * Update volume limit props - adjusts max value based on day/night mode
1507
+ * @param {number} maxVolume - Maximum volume limit (0-16)
1508
+ */
1509
+ updateVolumeLimitProps (maxVolume) {
1510
+ if (!this.volumeService) return
1511
+
1512
+ const { Characteristic } = this.#platform
1513
+ const maxVolumeSteps = Number.isFinite(maxVolume) ? maxVolume : 16
1514
+ const clampedMaxVolume = Math.max(0, Math.min(maxVolumeSteps, 16))
1515
+ this.volumeService
1516
+ .getCharacteristic(Characteristic.RotationSpeed)
1517
+ .setProps({
1518
+ minValue: 0,
1519
+ maxValue: clampedMaxVolume,
1520
+ minStep: 1,
1521
+ })
1522
+
1523
+ this.#log.debug(`[${this.#device.name}] Volume max is ${clampedMaxVolume}/16`)
1524
+ }
1525
+
1526
+ /**
1527
+ * Update mute characteristic
1528
+ * @param {number} volume - Volume level
1529
+ */
1530
+ updateMuteCharacteristic (volume) {
1531
+ if (!this.volumeService) return
1532
+
1533
+ const { Characteristic } = this.#platform
1534
+ const active = volume === 0
1535
+ ? Characteristic.Active.INACTIVE
1536
+ : Characteristic.Active.ACTIVE
1537
+ this.volumeService
1538
+ .getCharacteristic(Characteristic.Active)
1539
+ .updateValue(active)
1540
+ }
1541
+
1542
+ /**
1543
+ * Update battery level characteristic
1544
+ * @param {number} batteryLevel - Battery level percentage
1545
+ */
1546
+ updateBatteryLevelCharacteristic (batteryLevel) {
1547
+ if (!this.batteryService) return
1548
+
1549
+ const { Characteristic } = this.#platform
1550
+ this.batteryService
1551
+ .getCharacteristic(Characteristic.BatteryLevel)
1552
+ .updateValue(batteryLevel)
1553
+ }
1554
+
1555
+ /**
1556
+ * Update low battery characteristic
1557
+ * @param {number} batteryLevel - Battery level percentage
1558
+ */
1559
+ updateLowBatteryCharacteristic (batteryLevel) {
1560
+ if (!this.batteryService) return
1561
+
1562
+ const { Characteristic } = this.#platform
1563
+ const lowBattery = batteryLevel <= LOW_BATTERY_THRESHOLD
1564
+ ? Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW
1565
+ : Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL
1566
+
1567
+ this.batteryService
1568
+ .getCharacteristic(Characteristic.StatusLowBattery)
1569
+ .updateValue(lowBattery)
1570
+ }
1571
+
1572
+ /**
1573
+ * Update charging state characteristic
1574
+ * @param {boolean} isCharging - Is device charging
1575
+ */
1576
+ updateChargingStateCharacteristic (isCharging) {
1577
+ if (!this.batteryService) return
1578
+
1579
+ const { Characteristic } = this.#platform
1580
+ const chargingState = isCharging
1581
+ ? Characteristic.ChargingState.CHARGING
1582
+ : Characteristic.ChargingState.NOT_CHARGING
1583
+
1584
+ this.batteryService
1585
+ .getCharacteristic(Characteristic.ChargingState)
1586
+ .updateValue(chargingState)
1587
+ }
1588
+
1589
+ /**
1590
+ * Update online status characteristic for all device-state services
1591
+ *
1592
+ * Services that need StatusActive (read device state, unavailable when offline):
1593
+ * - Switch services (playback, seek)
1594
+ * - Speaker (volume, mute)
1595
+ * - Battery (battery level, charging state)
1596
+ * - TemperatureSensor (temperature reading)
1597
+ * - ContactSensor (card insertion state) - when implemented
1598
+ * - OccupancySensor (day/night mode from device) - when implemented
1599
+ * - Switch (Sleep Timer - reads device playback state) - when implemented
1600
+ *
1601
+ * Services that DON'T need StatusActive (config-based, work offline):
1602
+ * - Lightbulb services (ambient lights - config only)
1603
+ * - Fanv2 services (max volume - config only)
1604
+ * - Switch (Bluetooth - config only)
1605
+ * - StatelessProgrammableSwitch (shortcuts - config only)
1606
+ *
1607
+ * @param {boolean} isOnline - Online status
1608
+ */
1609
+ updateOnlineStatusCharacteristic (isOnline) {
1610
+ const { Characteristic } = this.#platform
1611
+
1612
+ // Update playback/volume services (device state)
1613
+ if (this.playbackService) {
1614
+ this.playbackService
1615
+ .getCharacteristic(Characteristic.StatusActive)
1616
+ .updateValue(isOnline)
1617
+ }
1618
+ if (this.volumeService) {
1619
+ this.volumeService
1620
+ .getCharacteristic(Characteristic.StatusActive)
1621
+ .updateValue(isOnline)
1622
+ }
1623
+
1624
+ // Update Battery (battery level, charging state)
1625
+ // Battery Doesn't support this
1626
+ // if (this.batteryService) {
1627
+ // this.batteryService
1628
+ // .getCharacteristic(Characteristic.StatusActive)
1629
+ // .updateValue(isOnline)
1630
+ // }
1631
+
1632
+ // Update TemperatureSensor (temperature reading)
1633
+ if (this.temperatureSensorService) {
1634
+ this.temperatureSensorService
1635
+ .getCharacteristic(Characteristic.StatusActive)
1636
+ .updateValue(isOnline)
1637
+ }
1638
+
1639
+ // Update nightlight status ContactSensors (device state)
1640
+ if (this.nightlightActiveService) {
1641
+ this.nightlightActiveService
1642
+ .getCharacteristic(Characteristic.StatusActive)
1643
+ .updateValue(isOnline)
1644
+ }
1645
+ if (this.dayNightlightActiveService) {
1646
+ this.dayNightlightActiveService
1647
+ .getCharacteristic(Characteristic.StatusActive)
1648
+ .updateValue(isOnline)
1649
+ }
1650
+ if (this.nightNightlightActiveService) {
1651
+ this.nightNightlightActiveService
1652
+ .getCharacteristic(Characteristic.StatusActive)
1653
+ .updateValue(isOnline)
1654
+ }
1655
+
1656
+ // Update card slot ContactSensor (device state)
1657
+ if (this.cardSlotService) {
1658
+ this.cardSlotService
1659
+ .getCharacteristic(Characteristic.StatusActive)
1660
+ .updateValue(isOnline)
1661
+ }
1662
+
1663
+ // Update night mode OccupancySensor (device state)
1664
+ if (this.nightModeService) {
1665
+ this.nightModeService
1666
+ .getCharacteristic(Characteristic.StatusActive)
1667
+ .updateValue(isOnline)
1668
+ }
1669
+
1670
+ // Update sleep timer Switch (reads device playback state)
1671
+ if (this.sleepTimerService) {
1672
+ this.sleepTimerService
1673
+ .getCharacteristic(Characteristic.StatusActive)
1674
+ .updateValue(isOnline)
1675
+ }
1676
+
1677
+ // Note: Config-based services (Nightlight Lightbulbs, Fanv2, Bluetooth Switch, Shortcuts)
1678
+ // do NOT get StatusActive updated - they work offline since they only read/write config
1679
+
1680
+ // TODO: Add shortcut services when implemented
1681
+ // if (this.shortcutServices) {
1682
+ // for (const service of this.shortcutServices) {
1683
+ // service
1684
+ // .getCharacteristic(this.platform.Characteristic.StatusActive)
1685
+ // .updateValue(isOnline)
1686
+ // }
1687
+ // }
1688
+ }
1689
+
1690
+ /**
1691
+ * Update firmware version characteristic
1692
+ * @param {string} firmwareVersion - Firmware version
1693
+ */
1694
+ updateFirmwareVersionCharacteristic (firmwareVersion) {
1695
+ const { Service, Characteristic } = this.#platform
1696
+ const infoService = this.#accessory.getService(Service.AccessoryInformation)
1697
+ if (!infoService) return
1698
+
1699
+ infoService.setCharacteristic(
1700
+ Characteristic.FirmwareRevision,
1701
+ firmwareVersion
1702
+ )
1703
+ }
1704
+
1705
+ /**
1706
+ * Update nightlight status ContactSensor characteristics
1707
+ */
1708
+ updateNightlightStatusCharacteristics () {
1709
+ const { Characteristic } = this.#platform
1710
+ const status = this.#deviceModel.status
1711
+
1712
+ if (this.nightlightActiveService) {
1713
+ const isActive = status.nightlightMode !== 'off'
1714
+ this.nightlightActiveService
1715
+ .getCharacteristic(Characteristic.ContactSensorState)
1716
+ .updateValue(isActive ? Characteristic.ContactSensorState.CONTACT_DETECTED : Characteristic.ContactSensorState.CONTACT_NOT_DETECTED)
1717
+ }
1718
+
1719
+ if (this.dayNightlightActiveService) {
1720
+ const isDay = status.dayMode === 'day'
1721
+ const isActive = status.nightlightMode !== 'off'
1722
+ const isShowing = isDay && isActive
1723
+ this.dayNightlightActiveService
1724
+ .getCharacteristic(Characteristic.ContactSensorState)
1725
+ .updateValue(isShowing ? Characteristic.ContactSensorState.CONTACT_DETECTED : Characteristic.ContactSensorState.CONTACT_NOT_DETECTED)
1726
+ }
1727
+
1728
+ if (this.nightNightlightActiveService) {
1729
+ const isNight = status.dayMode === 'night'
1730
+ const isActive = status.nightlightMode !== 'off'
1731
+ const isShowing = isNight && isActive
1732
+ this.nightNightlightActiveService
1733
+ .getCharacteristic(Characteristic.ContactSensorState)
1734
+ .updateValue(isShowing ? Characteristic.ContactSensorState.CONTACT_DETECTED : Characteristic.ContactSensorState.CONTACT_NOT_DETECTED)
1735
+ }
1736
+ }
1737
+
1738
+ /**
1739
+ * Update card slot ContactSensor characteristic
1740
+ */
1741
+ updateCardSlotCharacteristic () {
1742
+ if (!this.cardSlotService) {
1743
+ return
1744
+ }
1745
+
1746
+ const { Characteristic } = this.#platform
1747
+ const status = this.#deviceModel.status
1748
+ const hasCard = status.cardInsertionState !== 'none'
1749
+
1750
+ this.cardSlotService
1751
+ .getCharacteristic(Characteristic.ContactSensorState)
1752
+ .updateValue(hasCard ? Characteristic.ContactSensorState.CONTACT_DETECTED : Characteristic.ContactSensorState.CONTACT_NOT_DETECTED)
1753
+ }
1754
+
1755
+ /**
1756
+ * Update night mode OccupancySensor characteristic
1757
+ */
1758
+ updateNightModeCharacteristic () {
1759
+ if (!this.nightModeService) {
1760
+ return
1761
+ }
1762
+
1763
+ const { Characteristic } = this.#platform
1764
+ const status = this.#deviceModel.status
1765
+ const isNightMode = status.dayMode === 'night'
1766
+
1767
+ this.nightModeService
1768
+ .getCharacteristic(Characteristic.OccupancyDetected)
1769
+ .updateValue(isNightMode ? Characteristic.OccupancyDetected.OCCUPANCY_DETECTED : Characteristic.OccupancyDetected.OCCUPANCY_NOT_DETECTED)
1770
+ }
1771
+
1772
+ /**
1773
+ * Update sleep timer Switch characteristic
1774
+ */
1775
+ updateSleepTimerCharacteristic () {
1776
+ if (!this.sleepTimerService) {
1777
+ return
1778
+ }
1779
+
1780
+ const { Characteristic } = this.#platform
1781
+ const playback = this.#deviceModel.playback
1782
+
1783
+ this.sleepTimerService
1784
+ .getCharacteristic(Characteristic.On)
1785
+ .updateValue(playback.sleepTimerActive)
1786
+ }
1787
+
1788
+ /**
1789
+ * Update Bluetooth Switch characteristic
1790
+ */
1791
+ updateBluetoothCharacteristic () {
1792
+ if (!this.bluetoothService) {
1793
+ return
1794
+ }
1795
+
1796
+ const { Characteristic } = this.#platform
1797
+ const enabled = this.#deviceModel.config.bluetoothEnabled
1798
+
1799
+ this.bluetoothService
1800
+ .getCharacteristic(Characteristic.On)
1801
+ .updateValue(enabled)
1802
+ }
1803
+
1804
+ /**
1805
+ * Update volume limit Fanv2 characteristics
1806
+ */
1807
+ updateVolumeLimitCharacteristics () {
1808
+ const config = this.#deviceModel.config
1809
+ const { Characteristic } = this.#platform
1810
+
1811
+ if (this.dayMaxVolumeService) {
1812
+ const limit = Number.isFinite(config.maxVolumeLimit) ? config.maxVolumeLimit : 16
1813
+ this.dayMaxVolumeService
1814
+ .getCharacteristic(Characteristic.RotationSpeed)
1815
+ .updateValue(limit)
1816
+ }
1817
+
1818
+ if (this.nightMaxVolumeService) {
1819
+ const limit = Number.isFinite(config.nightMaxVolumeLimit) ? config.nightMaxVolumeLimit : 10
1820
+ this.nightMaxVolumeService
1821
+ .getCharacteristic(Characteristic.RotationSpeed)
1822
+ .updateValue(limit)
1823
+ }
1824
+ }
1825
+
1826
+ /**
1827
+ * Update temperature characteristic and fault status
1828
+ * @param {string | number | null} temperature - Temperature in Celsius
1829
+ */
1830
+ updateTemperatureCharacteristic (temperature) {
1831
+ if (!this.temperatureSensorService) return
1832
+
1833
+ // Skip if temperature is not available
1834
+ if (temperature === null || temperature === 'notSupported') {
1835
+ return
1836
+ }
1837
+
1838
+ const { Characteristic } = this.#platform
1839
+ const temp = Number(temperature)
1840
+ this.temperatureSensorService
1841
+ .getCharacteristic(Characteristic.CurrentTemperature)
1842
+ .updateValue(temp)
1843
+
1844
+ // Update fault status
1845
+ const fault = Characteristic.StatusFault.NO_FAULT
1846
+ this.temperatureSensorService
1847
+ .getCharacteristic(Characteristic.StatusFault)
1848
+ .updateValue(fault)
1849
+ }
1850
+
1851
+ // ==================== Lifecycle Methods ====================
1852
+
1853
+ /**
1854
+ * Stop accessory - cleanup event listeners
1855
+ * @returns {Promise<void>}
1856
+ */
1857
+ async stop () {
1858
+ this.#log.info(LOG_PREFIX.ACCESSORY, `Stopping ${this.#device.name}`)
1859
+
1860
+ // Remove all event listeners from device model
1861
+ this.#deviceModel.removeAllListeners('statusUpdate')
1862
+ this.#deviceModel.removeAllListeners('configUpdate')
1863
+ this.#deviceModel.removeAllListeners('playbackUpdate')
1864
+ this.#deviceModel.removeAllListeners('online')
1865
+ this.#deviceModel.removeAllListeners('offline')
1866
+ this.#deviceModel.removeAllListeners('error')
1867
+
1868
+ // Note: Don't call deviceModel.stop() here - that's handled by YotoAccount
1869
+ }
1870
+ }