@mdjhnson/homebridge-yoto 0.1.0

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,2350 @@
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 { YotoDeviceModelEventMap } from 'yoto-nodejs-client/lib/yoto-device.js' */
9
+ /** @import { YotoDevice } from 'yoto-nodejs-client/lib/api-endpoints/devices.js' */
10
+ /** @import { YotoAccessoryContext } from './platform.js' */
11
+ /** @import { ServiceSchemaKey } from '../config.schema.cjs' */
12
+ /** @import { ShortcutEntry } from './shortcuts.js' */
13
+ /** @import { PlayableCard } from './card-controls.js' */
14
+
15
+ /**
16
+ * Device capabilities detected from metadata
17
+ * @typedef {Object} YotoDeviceCapabilities
18
+ * @property {boolean} hasTemperatureSensor - Whether device has temperature sensor (Gen3 only)
19
+ * @property {string | undefined} formFactor - Device form factor ('standard' or 'mini')
20
+ * @property {string | undefined} generation - Device generation (e.g., 'gen3')
21
+ */
22
+
23
+ /**
24
+ * Accessory service toggles from config.
25
+ * @typedef {Object} YotoServiceToggles
26
+ * @property {boolean} playback
27
+ * @property {boolean} volume
28
+ * @property {boolean} battery
29
+ * @property {boolean} temperature
30
+ * @property {boolean} nightlight
31
+ * @property {boolean} cardSlot
32
+ * @property {boolean} dayMode
33
+ * @property {boolean} sleepTimer
34
+ * @property {boolean} bluetooth
35
+ * @property {boolean} volumeLimits
36
+ * @property {boolean} shortcuts
37
+ */
38
+
39
+ import convert from 'color-convert'
40
+ import {
41
+ DEFAULT_MANUFACTURER,
42
+ DEFAULT_MODEL,
43
+ LOW_BATTERY_THRESHOLD,
44
+ LOG_PREFIX,
45
+ } from './settings.js'
46
+ import { sanitizeName } from './utils/sanitize-name.js'
47
+ import { syncServiceNames } from './sync-service-names.js'
48
+ import { serviceSchema } from '../config.schema.cjs'
49
+ import { getPlaybackAccessoryConfig } from './service-config.js'
50
+ import { getCardControlConfigs } from './card-controls.js'
51
+ import { getCardStartOptions, getShortcutEntries, getShortcutsSignature, toPlayableCard } from './shortcuts.js'
52
+ import { formatError } from './utils/error-format.js'
53
+ import { ListenerGroup } from './utils/listener-group.js'
54
+ import { setDeviceVolume } from './utils/set-device-volume.js'
55
+ import { getBooleanSetting } from './utils/get-boolean-setting.js'
56
+ import {
57
+ clampPercent,
58
+ clampSteps,
59
+ percentToSteps,
60
+ stepsToPercent,
61
+ } from './utils/volume.js'
62
+
63
+ /**
64
+ * @param {ServiceSchemaKey} key
65
+ * @returns {boolean}
66
+ */
67
+ function getServiceDefault (key) {
68
+ const entry = serviceSchema[key]
69
+ if (entry && 'default' in entry && typeof entry.default === 'boolean') {
70
+ return entry.default
71
+ }
72
+ return false
73
+ }
74
+
75
+ /**
76
+ * Yoto Player Accessory Handler
77
+ * Manages HomeKit services and characteristics for a single Yoto player
78
+ */
79
+ export class YotoPlayerAccessory {
80
+ /** @type {YotoPlatform} */ #platform
81
+ /** @type {PlatformAccessory<YotoAccessoryContext>} */ #accessory
82
+ /** @type {YotoDeviceModel} */ #deviceModel
83
+ /** @type {Logger} */ #log
84
+ /** @type {YotoDevice} */ #device
85
+ /** @type {Service | undefined} */ playbackService
86
+ /** @type {Service | undefined} */ volumeService
87
+ /** @type {Service | undefined} */ batteryService
88
+ /** @type {Service | undefined} */ onlineStatusService
89
+ /** @type {Service | undefined} */ temperatureSensorService
90
+ /** @type {Service | undefined} */ dayNightlightService
91
+ /** @type {Service | undefined} */ nightNightlightService
92
+ /** @type {Service | undefined} */ nightlightActiveService
93
+ /** @type {Service | undefined} */ dayNightlightActiveService
94
+ /** @type {Service | undefined} */ nightNightlightActiveService
95
+ /** @type {Service | undefined} */ cardSlotService
96
+ /** @type {Service | undefined} */ dayModeService
97
+ /** @type {Service | undefined} */ sleepTimerService
98
+ /** @type {Service | undefined} */ bluetoothService
99
+ /** @type {Service | undefined} */ dayMaxVolumeService
100
+ /** @type {Service | undefined} */ nightMaxVolumeService
101
+ // Volume state for mute/unmute (0-100 percent)
102
+ /** @type {number} */ #lastNonZeroVolume = 50
103
+ // Nightlight color state for restore-on-ON
104
+ /** @type {string} */ #lastDayColor = '0xffffff'
105
+ /** @type {string} */ #lastNightColor = '0xffffff'
106
+ /** @type {Set<Service>} */ #currentServices = new Set()
107
+ /** @type {ListenerGroup<YotoDeviceModelEventMap>} */ #listeners
108
+ /** @type {Map<string, Service>} */ #shortcutServices = new Map()
109
+ /** @type {string} */ #shortcutsSignature = ''
110
+
111
+ /**
112
+ * @param {Object} params
113
+ * @param {YotoPlatform} params.platform - Platform instance
114
+ * @param {PlatformAccessory<YotoAccessoryContext>} params.accessory - Platform accessory
115
+ * @param {YotoDeviceModel} params.deviceModel - Yoto device model with live state
116
+ */
117
+ constructor ({ platform, accessory, deviceModel }) {
118
+ this.#platform = platform
119
+ this.#accessory = accessory
120
+ this.#deviceModel = deviceModel
121
+ this.#listeners = new ListenerGroup(deviceModel)
122
+ this.#log = platform.log
123
+
124
+ // Extract device info from context
125
+ this.#device = accessory.context.device
126
+
127
+ // Track all services we add during setup
128
+ this.#currentServices = new Set()
129
+ }
130
+
131
+ /**
132
+ * @returns {YotoServiceToggles}
133
+ */
134
+ getServiceToggles () {
135
+ const config = this.#platform.config
136
+ const services = config && typeof config === 'object' ? config['services'] : undefined
137
+ const serviceConfig = typeof services === 'object' && services !== null
138
+ ? /** @type {Record<string, unknown>} */ (services)
139
+ : {}
140
+ const playbackConfig = getPlaybackAccessoryConfig(this.#platform.config)
141
+
142
+ return {
143
+ playback: playbackConfig.playbackEnabled,
144
+ volume: playbackConfig.volumeEnabled,
145
+ battery: getBooleanSetting(serviceConfig['battery'], getServiceDefault('battery')),
146
+ temperature: getBooleanSetting(serviceConfig['temperature'], getServiceDefault('temperature')),
147
+ nightlight: getBooleanSetting(serviceConfig['nightlight'], getServiceDefault('nightlight')),
148
+ cardSlot: getBooleanSetting(serviceConfig['cardSlot'], getServiceDefault('cardSlot')),
149
+ dayMode: getBooleanSetting(serviceConfig['dayMode'], getServiceDefault('dayMode')),
150
+ sleepTimer: getBooleanSetting(serviceConfig['sleepTimer'], getServiceDefault('sleepTimer')),
151
+ bluetooth: getBooleanSetting(serviceConfig['bluetooth'], getServiceDefault('bluetooth')),
152
+ volumeLimits: getBooleanSetting(serviceConfig['volumeLimits'], getServiceDefault('volumeLimits')),
153
+ shortcuts: getBooleanSetting(serviceConfig['shortcuts'], getServiceDefault('shortcuts')),
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Setup accessory - create services and setup event listeners
159
+ * @returns {Promise<void>}
160
+ */
161
+ async setup () {
162
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Setting up ${this.#device.name}`)
163
+
164
+ // Check if device type is supported
165
+ if (!this.#deviceModel.capabilities.supported) {
166
+ // e.g. newer Minis report 'minie'; basic features still work
167
+ this.#log.info(
168
+ LOG_PREFIX.ACCESSORY,
169
+ `[${this.#device.name}] Unrecognized Yoto model '${this.#device.deviceType}', showing standard features only (no temperature or nightlight controls)`
170
+ )
171
+ }
172
+
173
+ // Clear the set before setup (in case setup is called multiple times)
174
+ this.#currentServices.clear()
175
+
176
+ // 1. Setup services
177
+ const serviceToggles = this.getServiceToggles()
178
+
179
+ this.setupAccessoryInformation()
180
+ this.setupOnlineStatusService()
181
+
182
+ if (serviceToggles.playback) {
183
+ this.setupPlaybackSwitchService()
184
+ }
185
+
186
+ if (serviceToggles.volume) {
187
+ this.setupVolumeService()
188
+ }
189
+
190
+ if (serviceToggles.battery) {
191
+ this.setupBatteryService()
192
+ }
193
+
194
+ // Setup optional services based on device capabilities
195
+ if (serviceToggles.temperature && this.#deviceModel.capabilities.hasTemperatureSensor) {
196
+ this.setupTemperatureSensorService()
197
+ }
198
+
199
+ if (serviceToggles.nightlight && this.#deviceModel.capabilities.hasColoredNightlight) {
200
+ this.setupNightlightServices()
201
+ }
202
+
203
+ // Setup universal services (available on all devices)
204
+ if (serviceToggles.cardSlot) {
205
+ this.setupCardSlotService()
206
+ }
207
+ if (serviceToggles.dayMode) {
208
+ this.setupDayModeService()
209
+ }
210
+ if (serviceToggles.sleepTimer) {
211
+ this.setupSleepTimerService()
212
+ }
213
+ if (serviceToggles.bluetooth) {
214
+ this.setupBluetoothService()
215
+ }
216
+ if (serviceToggles.volumeLimits) {
217
+ this.setupVolumeLimitServices()
218
+ }
219
+ this.setupCardControlServices()
220
+ if (serviceToggles.shortcuts) {
221
+ this.setupShortcutServices()
222
+ }
223
+
224
+ // Remove any services that aren't in our current set
225
+ // (except AccessoryInformation which should always be preserved)
226
+ // Iterate a copy: removeService splices the live array
227
+ for (const service of [...this.#accessory.services]) {
228
+ if (service.UUID !== this.#platform.Service.AccessoryInformation.UUID &&
229
+ !this.#currentServices.has(service)) {
230
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Removing stale service: ${service.displayName || service.UUID}`)
231
+ this.#accessory.removeService(service)
232
+ }
233
+ }
234
+
235
+ // 2. Setup event listeners for device model updates
236
+ this.setupEventListeners()
237
+
238
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `✓ ${this.#device.name} ready`)
239
+ }
240
+
241
+ /**
242
+ * Generate service name with device name prefix
243
+ * @param {string} serviceName - Base service name
244
+ * @returns {string} Full service name with device prefix
245
+ */
246
+ generateServiceName (serviceName) {
247
+ const rawName = `${this.#device.name} ${serviceName}`
248
+ return sanitizeName(rawName)
249
+ }
250
+
251
+ /**
252
+ * Setup AccessoryInformation service
253
+ */
254
+ setupAccessoryInformation () {
255
+ const { Service, Characteristic } = this.#platform
256
+ const service = this.#accessory.getService(Service.AccessoryInformation) ||
257
+ this.#accessory.addService(Service.AccessoryInformation)
258
+ const displayName = sanitizeName(this.#accessory.displayName)
259
+ const nameCharacteristic = service.getCharacteristic(Characteristic.Name)
260
+ const configuredCharacteristic = service.getCharacteristic(Characteristic.ConfiguredName)
261
+ const previousName = nameCharacteristic.value
262
+ const configuredName = configuredCharacteristic.value
263
+
264
+ // Build hardware revision from generation and form factor
265
+ const hardwareRevision = [
266
+ this.#device.generation,
267
+ this.#device.formFactor,
268
+ ].filter(Boolean).join(' ') || 'Unknown'
269
+
270
+ // Use deviceFamily for model (e.g., 'v2', 'v3', 'mini')
271
+ const model = this.#device.deviceFamily || this.#device.deviceType || DEFAULT_MODEL
272
+
273
+ // Set standard characteristics
274
+ service
275
+ .setCharacteristic(Characteristic.Name, displayName)
276
+ .setCharacteristic(Characteristic.Manufacturer, DEFAULT_MANUFACTURER)
277
+ .setCharacteristic(Characteristic.Model, model)
278
+ .setCharacteristic(Characteristic.SerialNumber, this.#device.deviceId)
279
+ .setCharacteristic(Characteristic.HardwareRevision, hardwareRevision)
280
+
281
+ if (typeof configuredName !== 'string' || configuredName === previousName) {
282
+ service.setCharacteristic(Characteristic.ConfiguredName, displayName)
283
+ }
284
+
285
+ // Set firmware version from live status if available
286
+ if (this.#deviceModel.status.firmwareVersion) {
287
+ service.setCharacteristic(
288
+ Characteristic.FirmwareRevision,
289
+ this.#deviceModel.status.firmwareVersion
290
+ )
291
+ }
292
+
293
+ this.#currentServices.add(service)
294
+ }
295
+
296
+ /**
297
+ * Setup online/offline ContactSensor service (PRIMARY)
298
+ */
299
+ setupOnlineStatusService () {
300
+ const { Service, Characteristic } = this.#platform
301
+ const serviceName = this.generateServiceName('Online Status')
302
+
303
+ const service = this.#accessory.getServiceById(Service.ContactSensor, 'OnlineStatus') ||
304
+ this.#accessory.addService(Service.ContactSensor, serviceName, 'OnlineStatus')
305
+
306
+ service.setPrimaryService(true)
307
+
308
+ syncServiceNames({ Characteristic, service, name: serviceName })
309
+
310
+ service
311
+ .getCharacteristic(Characteristic.ContactSensorState)
312
+ .onGet(this.getOnlineStatus.bind(this))
313
+
314
+ this.onlineStatusService = service
315
+ this.#currentServices.add(service)
316
+ }
317
+
318
+ /**
319
+ * Setup play/pause Switch service
320
+ */
321
+ setupPlaybackSwitchService () {
322
+ const { Service, Characteristic } = this.#platform
323
+ const serviceName = this.generateServiceName('Playback')
324
+
325
+ const service = this.#accessory.getServiceById(Service.Switch, 'Playback') ||
326
+ this.#accessory.addService(Service.Switch, serviceName, 'Playback')
327
+
328
+ syncServiceNames({ Characteristic, service, name: serviceName })
329
+
330
+ service
331
+ .getCharacteristic(Characteristic.On)
332
+ .onGet(this.getPlaybackOn.bind(this))
333
+ .onSet(this.setPlaybackOn.bind(this))
334
+
335
+ this.playbackService = service
336
+ this.#currentServices.add(service)
337
+ }
338
+
339
+ /**
340
+ * Setup Lightbulb service for volume/mute controls (Speaker service isn't shown in Home)
341
+ */
342
+ setupVolumeService () {
343
+ const { Service, Characteristic } = this.#platform
344
+ const serviceName = this.generateServiceName('Volume')
345
+
346
+ const service = this.#accessory.getServiceById(Service.Lightbulb, 'Volume') ||
347
+ this.#accessory.addService(Service.Lightbulb, serviceName, 'Volume')
348
+ syncServiceNames({ Characteristic, service, name: serviceName })
349
+
350
+ service
351
+ .getCharacteristic(Characteristic.On)
352
+ .onGet(this.getVolumeOn.bind(this))
353
+ .onSet(this.setVolumeOn.bind(this))
354
+
355
+ service
356
+ .getCharacteristic(Characteristic.Brightness)
357
+ .setProps({
358
+ minValue: 0,
359
+ maxValue: 100,
360
+ minStep: 1,
361
+ })
362
+ .onGet(this.getVolume.bind(this))
363
+ .onSet(this.setVolume.bind(this))
364
+
365
+ this.volumeService = service
366
+ this.#currentServices.add(service)
367
+ }
368
+
369
+ /**
370
+ * Setup Battery service
371
+ */
372
+ setupBatteryService () {
373
+ const { Service, Characteristic } = this.#platform
374
+ const serviceName = this.generateServiceName('Battery')
375
+ const service = this.#accessory.getService(Service.Battery) ||
376
+ this.#accessory.addService(Service.Battery, serviceName)
377
+ service.setCharacteristic(Characteristic.Name, serviceName)
378
+
379
+ // BatteryLevel (GET only)
380
+ service.getCharacteristic(Characteristic.BatteryLevel)
381
+ .onGet(this.getBatteryLevel.bind(this))
382
+
383
+ // ChargingState (GET only)
384
+ service.getCharacteristic(Characteristic.ChargingState)
385
+ .onGet(this.getChargingState.bind(this))
386
+
387
+ // StatusLowBattery (GET only)
388
+ service.getCharacteristic(Characteristic.StatusLowBattery)
389
+ .onGet(this.getStatusLowBattery.bind(this))
390
+
391
+ // Battery does not support StatusActive
392
+
393
+ this.batteryService = service
394
+ this.#currentServices.add(service)
395
+ }
396
+
397
+ /**
398
+ * Setup TemperatureSensor service (optional - only for devices with temperature sensor)
399
+ */
400
+ setupTemperatureSensorService () {
401
+ const { Service, Characteristic } = this.#platform
402
+ const serviceName = this.generateServiceName('Temperature')
403
+ const service = this.#accessory.getService(Service.TemperatureSensor) ||
404
+ this.#accessory.addService(Service.TemperatureSensor, serviceName)
405
+ syncServiceNames({ Characteristic, service, name: serviceName })
406
+
407
+ // CurrentTemperature (GET only)
408
+ service.getCharacteristic(Characteristic.CurrentTemperature)
409
+ .onGet(this.getCurrentTemperature.bind(this))
410
+
411
+ // StatusFault (GET only) - indicates if sensor is working
412
+ service.getCharacteristic(Characteristic.StatusFault)
413
+ .onGet(this.getTemperatureSensorFault.bind(this))
414
+
415
+ // StatusActive (online/offline indicator)
416
+ service.getCharacteristic(Characteristic.StatusActive)
417
+ .onGet(this.getStatusActive.bind(this))
418
+
419
+ this.temperatureSensorService = service
420
+ this.#currentServices.add(service)
421
+ }
422
+
423
+ /**
424
+ * Setup Nightlight services (optional - only for devices with colored nightlight)
425
+ * Creates two Lightbulb services for day and night nightlight color control
426
+ */
427
+ setupNightlightServices () {
428
+ const { Service, Characteristic } = this.#platform
429
+
430
+ // Day Nightlight
431
+ const dayName = this.generateServiceName('Day Nightlight')
432
+ const dayService = this.#accessory.getServiceById(Service.Lightbulb, 'DayNightlight') ||
433
+ this.#accessory.addService(Service.Lightbulb, dayName, 'DayNightlight')
434
+ syncServiceNames({ Characteristic, service: dayService, name: dayName })
435
+
436
+ // On (READ/WRITE) - Turn nightlight on/off
437
+ dayService.getCharacteristic(Characteristic.On)
438
+ .onGet(this.getDayNightlightOn.bind(this))
439
+ .onSet(this.setDayNightlightOn.bind(this))
440
+
441
+ // Brightness (READ/WRITE) - Display brightness (screen brightness, not color brightness)
442
+ dayService.getCharacteristic(Characteristic.Brightness)
443
+ .onGet(this.getDayNightlightBrightness.bind(this))
444
+ .onSet(this.setDayNightlightBrightness.bind(this))
445
+
446
+ // Hue (READ/WRITE) - Color hue from ambientColour
447
+ dayService.getCharacteristic(Characteristic.Hue)
448
+ .onGet(this.getDayNightlightHue.bind(this))
449
+ .onSet(this.setDayNightlightHue.bind(this))
450
+
451
+ // Saturation (READ/WRITE) - Color saturation from ambientColour
452
+ dayService.getCharacteristic(Characteristic.Saturation)
453
+ .onGet(this.getDayNightlightSaturation.bind(this))
454
+ .onSet(this.setDayNightlightSaturation.bind(this))
455
+
456
+ this.dayNightlightService = dayService
457
+
458
+ // Night Nightlight
459
+ const nightName = this.generateServiceName('Night Nightlight')
460
+ const nightService = this.#accessory.getServiceById(Service.Lightbulb, 'NightNightlight') ||
461
+ this.#accessory.addService(Service.Lightbulb, nightName, 'NightNightlight')
462
+ syncServiceNames({ Characteristic, service: nightService, name: nightName })
463
+
464
+ // On (READ/WRITE) - Turn nightlight on/off
465
+ nightService.getCharacteristic(Characteristic.On)
466
+ .onGet(this.getNightNightlightOn.bind(this))
467
+ .onSet(this.setNightNightlightOn.bind(this))
468
+
469
+ // Brightness (READ/WRITE) - Display brightness (screen brightness, not color brightness)
470
+ nightService.getCharacteristic(Characteristic.Brightness)
471
+ .onGet(this.getNightNightlightBrightness.bind(this))
472
+ .onSet(this.setNightNightlightBrightness.bind(this))
473
+
474
+ // Hue (READ/WRITE) - Color hue from nightAmbientColour
475
+ nightService.getCharacteristic(Characteristic.Hue)
476
+ .onGet(this.getNightNightlightHue.bind(this))
477
+ .onSet(this.setNightNightlightHue.bind(this))
478
+
479
+ // Saturation (READ/WRITE) - Color saturation from nightAmbientColour
480
+ nightService.getCharacteristic(Characteristic.Saturation)
481
+ .onGet(this.getNightNightlightSaturation.bind(this))
482
+ .onSet(this.setNightNightlightSaturation.bind(this))
483
+
484
+ this.nightNightlightService = nightService
485
+
486
+ // Setup nightlight status ContactSensors
487
+ // These show the live state of nightlights (different from config-based Lightbulb services)
488
+
489
+ // ContactSensor: NightlightActive - Shows if nightlight is currently on
490
+ const nightlightActiveName = this.generateServiceName('Nightlight Active')
491
+ const nightlightActiveService = this.#accessory.getServiceById(Service.ContactSensor, 'NightlightActive') ||
492
+ this.#accessory.addService(Service.ContactSensor, nightlightActiveName, 'NightlightActive')
493
+ syncServiceNames({ Characteristic, service: nightlightActiveService, name: nightlightActiveName })
494
+
495
+ nightlightActiveService.getCharacteristic(Characteristic.ContactSensorState)
496
+ .onGet(this.getNightlightActive.bind(this))
497
+ nightlightActiveService.getCharacteristic(Characteristic.StatusActive)
498
+ .onGet(this.getStatusActive.bind(this))
499
+
500
+ this.nightlightActiveService = nightlightActiveService
501
+
502
+ // ContactSensor: DayNightlightActive - Shows if day nightlight is currently showing
503
+ const dayNightlightActiveName = this.generateServiceName('Day Nightlight Active')
504
+ const dayNightlightActiveService = this.#accessory.getServiceById(Service.ContactSensor, 'DayNightlightActive') ||
505
+ this.#accessory.addService(Service.ContactSensor, dayNightlightActiveName, 'DayNightlightActive')
506
+ syncServiceNames({ Characteristic, service: dayNightlightActiveService, name: dayNightlightActiveName })
507
+
508
+ dayNightlightActiveService.getCharacteristic(Characteristic.ContactSensorState)
509
+ .onGet(this.getDayNightlightActive.bind(this))
510
+ dayNightlightActiveService.getCharacteristic(Characteristic.StatusActive)
511
+ .onGet(this.getStatusActive.bind(this))
512
+
513
+ this.dayNightlightActiveService = dayNightlightActiveService
514
+
515
+ // ContactSensor: NightNightlightActive - Shows if night nightlight is currently showing
516
+ const nightNightlightActiveName = this.generateServiceName('Night Nightlight Active')
517
+ const nightNightlightActiveService = this.#accessory.getServiceById(Service.ContactSensor, 'NightNightlightActive') ||
518
+ this.#accessory.addService(Service.ContactSensor, nightNightlightActiveName, 'NightNightlightActive')
519
+ syncServiceNames({ Characteristic, service: nightNightlightActiveService, name: nightNightlightActiveName })
520
+
521
+ nightNightlightActiveService.getCharacteristic(Characteristic.ContactSensorState)
522
+ .onGet(this.getNightNightlightActive.bind(this))
523
+ nightNightlightActiveService.getCharacteristic(Characteristic.StatusActive)
524
+ .onGet(this.getStatusActive.bind(this))
525
+
526
+ this.nightNightlightActiveService = nightNightlightActiveService
527
+
528
+ this.#currentServices.add(dayService)
529
+ this.#currentServices.add(nightService)
530
+ this.#currentServices.add(nightlightActiveService)
531
+ this.#currentServices.add(dayNightlightActiveService)
532
+ this.#currentServices.add(nightNightlightActiveService)
533
+ }
534
+
535
+ /**
536
+ * Setup card slot ContactSensor service
537
+ * Shows if a card is inserted in the player
538
+ */
539
+ setupCardSlotService () {
540
+ const { Service, Characteristic } = this.#platform
541
+ const serviceName = this.generateServiceName('Card Slot')
542
+
543
+ const service = this.#accessory.getServiceById(Service.ContactSensor, 'CardSlot') ||
544
+ this.#accessory.addService(Service.ContactSensor, serviceName, 'CardSlot')
545
+ syncServiceNames({ Characteristic, service, name: serviceName })
546
+
547
+ service.getCharacteristic(Characteristic.ContactSensorState)
548
+ .onGet(this.getCardSlotState.bind(this))
549
+ service.getCharacteristic(Characteristic.StatusActive)
550
+ .onGet(this.getStatusActive.bind(this))
551
+
552
+ this.cardSlotService = service
553
+ this.#currentServices.add(service)
554
+ }
555
+
556
+ /**
557
+ * Setup day mode ContactSensor service
558
+ * Shows if device is in day mode (vs night mode)
559
+ */
560
+ setupDayModeService () {
561
+ const { Service, Characteristic } = this.#platform
562
+ const serviceName = this.generateServiceName('Day Mode')
563
+
564
+ const service = this.#accessory.getServiceById(Service.ContactSensor, 'DayModeStatus') ||
565
+ this.#accessory.addService(Service.ContactSensor, serviceName, 'DayModeStatus')
566
+ syncServiceNames({ Characteristic, service, name: serviceName })
567
+
568
+ service.getCharacteristic(Characteristic.ContactSensorState)
569
+ .onGet(this.getDayModeStatus.bind(this))
570
+ service.getCharacteristic(Characteristic.StatusActive)
571
+ .onGet(this.getStatusActive.bind(this))
572
+
573
+ this.dayModeService = service
574
+ this.#currentServices.add(service)
575
+ }
576
+
577
+ /**
578
+ * Setup sleep timer Switch service
579
+ * Toggle sleep timer on/off
580
+ */
581
+ setupSleepTimerService () {
582
+ const { Service, Characteristic } = this.#platform
583
+ const serviceName = this.generateServiceName('Sleep Timer')
584
+
585
+ const service = this.#accessory.getServiceById(Service.Switch, 'SleepTimer') ||
586
+ this.#accessory.addService(Service.Switch, serviceName, 'SleepTimer')
587
+ syncServiceNames({ Characteristic, service, name: serviceName })
588
+
589
+ service.getCharacteristic(Characteristic.On)
590
+ .onGet(this.getSleepTimerState.bind(this))
591
+ .onSet(this.setSleepTimerState.bind(this))
592
+
593
+ this.sleepTimerService = service
594
+ this.#currentServices.add(service)
595
+ }
596
+
597
+ /**
598
+ * Setup Bluetooth Switch service
599
+ * Toggle Bluetooth on/off
600
+ */
601
+ setupBluetoothService () {
602
+ const { Service, Characteristic } = this.#platform
603
+ const serviceName = this.generateServiceName('Bluetooth')
604
+
605
+ const service = this.#accessory.getServiceById(Service.Switch, 'Bluetooth') ||
606
+ this.#accessory.addService(Service.Switch, serviceName, 'Bluetooth')
607
+ syncServiceNames({ Characteristic, service, name: serviceName })
608
+
609
+ service.getCharacteristic(Characteristic.On)
610
+ .onGet(this.getBluetoothState.bind(this))
611
+ .onSet(this.setBluetoothState.bind(this))
612
+
613
+ this.bluetoothService = service
614
+ this.#currentServices.add(service)
615
+ }
616
+
617
+ /**
618
+ * Setup volume limit Lightbulb services
619
+ * Control day and night mode max volume limits
620
+ */
621
+ setupVolumeLimitServices () {
622
+ const { Service, Characteristic } = this.#platform
623
+
624
+ // Day Max Volume
625
+ const dayName = this.generateServiceName('Day Max Volume')
626
+ const dayService = this.#accessory.getServiceById(Service.Lightbulb, 'DayMaxVolume') ||
627
+ this.#accessory.addService(Service.Lightbulb, dayName, 'DayMaxVolume')
628
+ syncServiceNames({ Characteristic, service: dayService, name: dayName })
629
+
630
+ dayService
631
+ .getCharacteristic(Characteristic.On)
632
+ .onGet(() => true)
633
+ .onSet((value) => {
634
+ if (!value) {
635
+ dayService.updateCharacteristic(Characteristic.On, true)
636
+ }
637
+ })
638
+ dayService.setCharacteristic(Characteristic.On, true)
639
+
640
+ dayService
641
+ .getCharacteristic(Characteristic.Brightness)
642
+ .setProps({ minValue: 0, maxValue: 100, minStep: 1 })
643
+ .onGet(this.getDayMaxVolume.bind(this))
644
+ .onSet(this.setDayMaxVolume.bind(this))
645
+
646
+ this.dayMaxVolumeService = dayService
647
+
648
+ // Night Max Volume
649
+ const nightName = this.generateServiceName('Night Max Volume')
650
+ const nightService = this.#accessory.getServiceById(Service.Lightbulb, 'NightMaxVolume') ||
651
+ this.#accessory.addService(Service.Lightbulb, nightName, 'NightMaxVolume')
652
+ syncServiceNames({ Characteristic, service: nightService, name: nightName })
653
+
654
+ nightService
655
+ .getCharacteristic(Characteristic.On)
656
+ .onGet(() => true)
657
+ .onSet((value) => {
658
+ if (!value) {
659
+ nightService.updateCharacteristic(Characteristic.On, true)
660
+ }
661
+ })
662
+ nightService.setCharacteristic(Characteristic.On, true)
663
+
664
+ nightService
665
+ .getCharacteristic(Characteristic.Brightness)
666
+ .setProps({ minValue: 0, maxValue: 100, minStep: 1 })
667
+ .onGet(this.getNightMaxVolume.bind(this))
668
+ .onSet(this.setNightMaxVolume.bind(this))
669
+
670
+ this.nightMaxVolumeService = nightService
671
+
672
+ this.#currentServices.add(dayService)
673
+ this.#currentServices.add(nightService)
674
+ }
675
+
676
+ /**
677
+ * Setup card control Switch services
678
+ */
679
+ setupCardControlServices () {
680
+ const cardControls = getCardControlConfigs(this.#platform.config)
681
+ if (cardControls.length === 0) {
682
+ return
683
+ }
684
+
685
+ const { Service, Characteristic } = this.#platform
686
+
687
+ for (const control of cardControls) {
688
+ const serviceName = this.generateServiceName(control.label)
689
+ const subtype = `CardControl:${control.id}`
690
+
691
+ const service = this.#accessory.getServiceById(Service.Switch, subtype) ||
692
+ this.#accessory.addService(Service.Switch, serviceName, subtype)
693
+
694
+ syncServiceNames({ Characteristic, service, name: serviceName })
695
+
696
+ service
697
+ .getCharacteristic(Characteristic.On)
698
+ .onGet(() => false)
699
+ .onSet(async (value) => {
700
+ await this.setCardControl(service, control, value)
701
+ })
702
+
703
+ service.updateCharacteristic(Characteristic.On, false)
704
+
705
+ this.#currentServices.add(service)
706
+ }
707
+ }
708
+
709
+ /**
710
+ * Setup shortcut Switch services (one per shortcut configured on the device).
711
+ * Removes switches for shortcuts that no longer exist.
712
+ */
713
+ setupShortcutServices () {
714
+ const { Service, Characteristic } = this.#platform
715
+ const entries = getShortcutEntries(this.#deviceModel.shortcuts)
716
+ this.#shortcutsSignature = getShortcutsSignature(entries)
717
+
718
+ /** @type {Map<string, Service>} */
719
+ const nextServices = new Map()
720
+
721
+ for (const entry of entries) {
722
+ const subtype = `Shortcut:${entry.id}`
723
+ const fallbackName = this.generateServiceName(entry.builtInName ?? `Shortcut ${entry.number}`)
724
+ const existing = this.#accessory.getServiceById(Service.Switch, subtype)
725
+ const service = existing || this.#accessory.addService(Service.Switch, fallbackName, subtype)
726
+
727
+ // Keep a previously resolved card title; otherwise start with the fallback.
728
+ // Built-in shortcuts always use their fixed name.
729
+ if (!existing || entry.builtInName) {
730
+ syncServiceNames({ Characteristic, service, name: fallbackName })
731
+ }
732
+
733
+ service
734
+ .getCharacteristic(Characteristic.On)
735
+ .onGet(() => false)
736
+ .onSet(async (value) => {
737
+ await this.setCardControl(service, this.getShortcutPlayable(service, entry), value)
738
+ })
739
+
740
+ service.updateCharacteristic(Characteristic.On, false)
741
+
742
+ nextServices.set(entry.id, service)
743
+ this.#currentServices.add(service)
744
+ if (!entry.builtInName) this.resolveShortcutName(service, entry)
745
+ }
746
+
747
+ for (const [id, service] of this.#shortcutServices) {
748
+ if (!nextServices.has(id)) {
749
+ this.#currentServices.delete(service)
750
+ this.#accessory.removeService(service)
751
+ }
752
+ }
753
+ this.#shortcutServices = nextServices
754
+ }
755
+
756
+ /**
757
+ * Rename a shortcut switch to its card title once the title is known.
758
+ * @param {Service} service
759
+ * @param {ShortcutEntry} entry
760
+ */
761
+ resolveShortcutName (service, entry) {
762
+ this.#platform.getCardTitle(entry.cardId).then(title => {
763
+ if (!title || this.#shortcutServices.get(entry.id) !== service) return
764
+ const name = this.generateServiceName(title)
765
+ if (service.displayName === name) return
766
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Naming shortcut ${entry.number}: ${title}`)
767
+ syncServiceNames({ Characteristic: this.#platform.Characteristic, service, name })
768
+ }).catch(error => {
769
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to name shortcut:`, formatError(error))
770
+ })
771
+ }
772
+
773
+ /**
774
+ * @param {Service} service
775
+ * @param {ShortcutEntry} entry
776
+ * @returns {PlayableCard}
777
+ */
778
+ getShortcutPlayable (service, entry) {
779
+ return toPlayableCard(entry, service.displayName || `Shortcut ${entry.number}`)
780
+ }
781
+
782
+ /**
783
+ * Rebuild shortcut switches if the device's shortcuts changed.
784
+ */
785
+ refreshShortcutServicesIfChanged () {
786
+ if (!this.getServiceToggles().shortcuts) return
787
+ const signature = getShortcutsSignature(getShortcutEntries(this.#deviceModel.shortcuts))
788
+ if (signature === this.#shortcutsSignature) return
789
+
790
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Shortcuts changed, updating switches`)
791
+ this.setupShortcutServices()
792
+ this.#platform.api.updatePlatformAccessories([this.#accessory])
793
+ }
794
+
795
+ /**
796
+ * Setup event listeners for device model updates
797
+ * Uses exhaustive switch pattern for type safety
798
+ */
799
+ setupEventListeners () {
800
+ // Status updates - exhaustive field checking
801
+ this.#listeners.on('statusUpdate', (status, _source, changedFields) => {
802
+ for (const field of changedFields) {
803
+ switch (field) {
804
+ case 'volume':
805
+ this.updateVolumeCharacteristic(status.volume)
806
+ this.updateMuteCharacteristic(status.volume)
807
+ break
808
+
809
+ case 'batteryLevelPercentage':
810
+ this.updateBatteryLevelCharacteristic(status.batteryLevelPercentage)
811
+ this.updateLowBatteryCharacteristic(status.batteryLevelPercentage)
812
+ break
813
+
814
+ case 'isCharging':
815
+ this.updateChargingStateCharacteristic(status.isCharging)
816
+ break
817
+
818
+ case 'isOnline':
819
+ this.updateOnlineStatusCharacteristic(status.isOnline)
820
+ break
821
+
822
+ case 'firmwareVersion':
823
+ this.updateFirmwareVersionCharacteristic(status.firmwareVersion)
824
+ break
825
+
826
+ case 'temperatureCelsius':
827
+ if (this.#deviceModel.capabilities.hasTemperatureSensor && status.temperatureCelsius !== null) {
828
+ this.updateTemperatureCharacteristic(status.temperatureCelsius)
829
+ }
830
+ break
831
+
832
+ case 'nightlightMode':
833
+ // Update nightlight status ContactSensors
834
+ if (this.#deviceModel.capabilities.hasColoredNightlight) {
835
+ this.updateNightlightStatusCharacteristics()
836
+ }
837
+ break
838
+
839
+ case 'dayMode':
840
+ // Update day mode ContactSensor
841
+ this.updateDayModeCharacteristic()
842
+ // Update nightlight status ContactSensors (depends on dayMode)
843
+ if (this.#deviceModel.capabilities.hasColoredNightlight) {
844
+ this.updateNightlightStatusCharacteristics()
845
+ }
846
+ break
847
+
848
+ case 'cardInsertionState':
849
+ this.updateCardSlotCharacteristic()
850
+ break
851
+
852
+ // Available but not yet mapped to characteristics
853
+ case 'activeCardId':
854
+ case 'maxVolume':
855
+ case 'powerSource':
856
+ case 'wifiStrength':
857
+ case 'freeDiskSpaceBytes':
858
+ case 'totalDiskSpaceBytes':
859
+ case 'isAudioDeviceConnected':
860
+ case 'isBluetoothAudioConnected':
861
+ case 'ambientLightSensorReading':
862
+ case 'displayBrightness':
863
+ case 'timeFormat':
864
+ case 'uptime':
865
+ case 'updatedAt':
866
+ case 'source':
867
+ // Not implemented - empty case documents availability
868
+ break
869
+
870
+ default: {
871
+ // Exhaustive check - TypeScript will error if a field is missed
872
+ /** @type {never} */
873
+ const _exhaustive = field
874
+ this.#log.debug('Unhandled status field:', _exhaustive)
875
+ break
876
+ }
877
+ }
878
+ }
879
+ })
880
+
881
+ // Config updates - exhaustive field checking
882
+ this.#listeners.on('configUpdate', (config, changedFields) => {
883
+ const { Characteristic } = this.#platform
884
+
885
+ // Shortcut changes emit configUpdate without a changed field name
886
+ this.refreshShortcutServicesIfChanged()
887
+
888
+ for (const field of changedFields) {
889
+ switch (field) {
890
+ case 'dayDisplayBrightness':
891
+ case 'dayDisplayBrightnessAuto': {
892
+ if (this.dayNightlightService) {
893
+ const raw = config.dayDisplayBrightnessAuto ? 100 : (config.dayDisplayBrightness ?? 100)
894
+ const brightness = Math.max(0, Math.min(Math.round(raw), 100))
895
+ this.dayNightlightService.updateCharacteristic(Characteristic.Brightness, brightness)
896
+ }
897
+ break
898
+ }
899
+
900
+ case 'nightDisplayBrightness':
901
+ case 'nightDisplayBrightnessAuto': {
902
+ if (this.nightNightlightService) {
903
+ const raw = config.nightDisplayBrightnessAuto ? 100 : (config.nightDisplayBrightness ?? 100)
904
+ const brightness = Math.max(0, Math.min(Math.round(raw), 100))
905
+ this.nightNightlightService.updateCharacteristic(Characteristic.Brightness, brightness)
906
+ }
907
+ break
908
+ }
909
+
910
+ case 'ambientColour': {
911
+ if (this.dayNightlightService) {
912
+ const isOn = !this.isColorOff(config.ambientColour)
913
+ this.dayNightlightService.updateCharacteristic(Characteristic.On, isOn)
914
+
915
+ if (isOn) {
916
+ const hex = this.parseHexColor(config.ambientColour)
917
+ const [h, s] = convert.hex.hsv(hex)
918
+ this.dayNightlightService.updateCharacteristic(Characteristic.Hue, h)
919
+ this.dayNightlightService.updateCharacteristic(Characteristic.Saturation, s)
920
+ }
921
+ }
922
+ break
923
+ }
924
+
925
+ case 'nightAmbientColour': {
926
+ if (this.nightNightlightService) {
927
+ const isOn = !this.isColorOff(config.nightAmbientColour)
928
+ this.nightNightlightService.updateCharacteristic(Characteristic.On, isOn)
929
+
930
+ if (isOn) {
931
+ const hex = this.parseHexColor(config.nightAmbientColour)
932
+ const [h, s] = convert.hex.hsv(hex)
933
+ this.nightNightlightService.updateCharacteristic(Characteristic.Hue, h)
934
+ this.nightNightlightService.updateCharacteristic(Characteristic.Saturation, s)
935
+ }
936
+ }
937
+ break
938
+ }
939
+
940
+ case 'maxVolumeLimit':
941
+ this.updateVolumeLimitCharacteristics()
942
+ break
943
+
944
+ case 'nightMaxVolumeLimit':
945
+ this.updateVolumeLimitCharacteristics()
946
+ break
947
+
948
+ case 'bluetoothEnabled':
949
+ this.updateBluetoothCharacteristic()
950
+ break
951
+
952
+ // Config fields available but not exposed as characteristics yet
953
+ case 'alarms':
954
+ case 'btHeadphonesEnabled':
955
+ case 'clockFace':
956
+ case 'dayTime':
957
+ case 'nightTime':
958
+ case 'dayYotoDaily':
959
+ case 'nightYotoDaily':
960
+ case 'dayYotoRadio':
961
+ case 'nightYotoRadio':
962
+ case 'nightYotoRadioEnabled':
963
+ case 'daySoundsOff':
964
+ case 'nightSoundsOff':
965
+ case 'displayDimBrightness':
966
+ case 'displayDimTimeout':
967
+ case 'headphonesVolumeLimited':
968
+ case 'hourFormat':
969
+ case 'locale':
970
+ case 'logLevel':
971
+ case 'pausePowerButton':
972
+ case 'pauseVolumeDown':
973
+ case 'repeatAll':
974
+ case 'showDiagnostics':
975
+ case 'shutdownTimeout':
976
+ case 'systemVolume':
977
+ case 'timezone':
978
+ case 'volumeLevel': {
979
+ // Not exposed - empty case documents availability
980
+ break
981
+ }
982
+
983
+ default: {
984
+ /** @type {never} */
985
+ const _exhaustive = field
986
+ this.#log.debug('Unhandled config field:', _exhaustive)
987
+ break
988
+ }
989
+ }
990
+ }
991
+ })
992
+
993
+ // Playback updates - exhaustive field checking
994
+ this.#listeners.on('playbackUpdate', (playback, changedFields) => {
995
+ for (const field of changedFields) {
996
+ switch (field) {
997
+ case 'playbackStatus':
998
+ this.updatePlaybackSwitchCharacteristic(playback.playbackStatus)
999
+ break
1000
+
1001
+ case 'position':
1002
+ case 'trackLength':
1003
+ break
1004
+
1005
+ case 'sleepTimerActive':
1006
+ this.updateSleepTimerCharacteristic()
1007
+ break
1008
+
1009
+ // Playback fields - informational only
1010
+ case 'cardId':
1011
+ case 'cardTitle':
1012
+ case 'cardSlug':
1013
+ case 'cardCoverImageUrl':
1014
+ case 'cardAuthor':
1015
+ case 'cardReadBy':
1016
+ case 'cardDurationSeconds':
1017
+ case 'source':
1018
+ case 'trackTitle':
1019
+ case 'trackKey':
1020
+ case 'chapterTitle':
1021
+ case 'chapterKey':
1022
+ case 'sleepTimerSeconds':
1023
+ case 'streaming':
1024
+ case 'updatedAt': {
1025
+ // Not exposed as characteristics
1026
+ break
1027
+ }
1028
+
1029
+ default: {
1030
+ /** @type {never} */
1031
+ const _exhaustive = field
1032
+ this.#log.debug('Unhandled playback field:', _exhaustive)
1033
+ break
1034
+ }
1035
+ }
1036
+ }
1037
+ })
1038
+
1039
+ // Lifecycle events
1040
+ this.#listeners.on('online', ({ reason: _reason }) => {
1041
+ // Platform logs online/offline events to avoid duplicate output.
1042
+ this.updateOnlineStatusCharacteristic(true)
1043
+ })
1044
+
1045
+ this.#listeners.on('offline', ({ reason: _reason }) => {
1046
+ // Platform logs online/offline events to avoid duplicate output.
1047
+ this.updateOnlineStatusCharacteristic(false)
1048
+ })
1049
+
1050
+ this.#listeners.on('error', (error) => {
1051
+ const details = formatError(error)
1052
+ this.#log.error(`[${this.#device.name}] Device error:`, details)
1053
+ })
1054
+ }
1055
+
1056
+ // ==================== Playback (Switch) Characteristic Handlers ====================
1057
+
1058
+ /**
1059
+ * Get play/pause state as a Switch "On" value
1060
+ * @returns {Promise<CharacteristicValue>}
1061
+ */
1062
+ async getPlaybackOn () {
1063
+ const isOn = this.#deviceModel.playback.playbackStatus === 'playing'
1064
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get playback switch -> ${isOn}`)
1065
+ return isOn
1066
+ }
1067
+
1068
+ /**
1069
+ * Set play/pause state via Switch "On"
1070
+ * @param {CharacteristicValue} value
1071
+ * @returns {Promise<void>}
1072
+ */
1073
+ async setPlaybackOn (value) {
1074
+ const isOn = Boolean(value)
1075
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Set playback switch:`, isOn)
1076
+
1077
+ try {
1078
+ if (isOn) {
1079
+ await this.#deviceModel.resumeCard()
1080
+ } else {
1081
+ await this.#deviceModel.pauseCard()
1082
+ }
1083
+ } catch (error) {
1084
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set playback state:`, formatError(error))
1085
+ throw new this.#platform.api.hap.HapStatusError(
1086
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1087
+ )
1088
+ }
1089
+ }
1090
+
1091
+ /**
1092
+ * Get volume level as percentage (mapped from 0-16 steps)
1093
+ * @returns {Promise<CharacteristicValue>}
1094
+ */
1095
+ async getVolume () {
1096
+ const volumeSteps = this.#deviceModel.status.volume
1097
+ const percent = stepsToPercent(volumeSteps)
1098
+ this.#log.debug(
1099
+ LOG_PREFIX.ACCESSORY,
1100
+ `[${this.#device.name}] Get volume rawSteps=${volumeSteps} percent=${percent}`
1101
+ )
1102
+ return percent
1103
+ }
1104
+
1105
+ /**
1106
+ * Set volume level as percentage (mapped to 0-16 steps)
1107
+ * @param {CharacteristicValue} value
1108
+ * @returns {Promise<void>}
1109
+ */
1110
+ async setVolume (value) {
1111
+ const deviceModel = this.#deviceModel
1112
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Set volume:`, value)
1113
+
1114
+ const requestedPercent = typeof value === 'number' ? value : Number(value)
1115
+ if (!Number.isFinite(requestedPercent)) {
1116
+ throw new this.#platform.api.hap.HapStatusError(
1117
+ this.#platform.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST
1118
+ )
1119
+ }
1120
+
1121
+ const normalizedPercent = clampPercent(requestedPercent)
1122
+ const requestedSteps = percentToSteps(normalizedPercent)
1123
+ const steps = clampSteps(requestedSteps)
1124
+ const resultPercent = stepsToPercent(steps)
1125
+ this.#log.debug(
1126
+ LOG_PREFIX.ACCESSORY,
1127
+ `[${this.#device.name}] Set volume raw=${value} normalizedPercent=${normalizedPercent} requestedSteps=${requestedSteps} -> steps=${steps} percent=${resultPercent}`
1128
+ )
1129
+
1130
+ // Track last non-zero volume for unmute
1131
+ if (steps > 0) {
1132
+ this.#lastNonZeroVolume = stepsToPercent(steps)
1133
+ }
1134
+
1135
+ try {
1136
+ await setDeviceVolume(deviceModel, steps)
1137
+ if (this.volumeService) {
1138
+ const { Characteristic } = this.#platform
1139
+ const clampedPercent = stepsToPercent(steps)
1140
+ this.volumeService
1141
+ .getCharacteristic(Characteristic.On)
1142
+ .updateValue(steps > 0)
1143
+
1144
+ if (steps !== requestedSteps || normalizedPercent !== requestedPercent) {
1145
+ this.volumeService
1146
+ .getCharacteristic(Characteristic.Brightness)
1147
+ .updateValue(clampedPercent)
1148
+ }
1149
+ }
1150
+ } catch (error) {
1151
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set volume:`, formatError(error))
1152
+ throw new this.#platform.api.hap.HapStatusError(
1153
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1154
+ )
1155
+ }
1156
+ }
1157
+
1158
+ /**
1159
+ * Get volume On state (derived from volume > 0)
1160
+ * @returns {Promise<CharacteristicValue>}
1161
+ */
1162
+ async getVolumeOn () {
1163
+ const isOn = this.#deviceModel.status.volume > 0
1164
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get volume on -> ${isOn}`)
1165
+ return isOn
1166
+ }
1167
+
1168
+ /**
1169
+ * Set volume On state (mute/unmute)
1170
+ * @param {CharacteristicValue} value
1171
+ * @returns {Promise<void>}
1172
+ */
1173
+ async setVolumeOn (value) {
1174
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Set volume on:`, value)
1175
+
1176
+ const isOn = Boolean(value)
1177
+ const currentVolume = this.#deviceModel.status.volume
1178
+
1179
+ if (!isOn) {
1180
+ if (currentVolume !== 0) {
1181
+ await this.setVolume(0)
1182
+ }
1183
+ return
1184
+ }
1185
+
1186
+ if (currentVolume === 0) {
1187
+ await this.setVolume(this.#lastNonZeroVolume)
1188
+ }
1189
+ }
1190
+
1191
+ /**
1192
+ * Get status active (online/offline)
1193
+ * @returns {Promise<CharacteristicValue>}
1194
+ */
1195
+ async getStatusActive () {
1196
+ const isOnline = this.#deviceModel.status.isOnline
1197
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get status active -> ${isOnline}`)
1198
+ return isOnline
1199
+ }
1200
+
1201
+ /**
1202
+ * Get online status as a ContactSensorState
1203
+ * @returns {Promise<CharacteristicValue>}
1204
+ */
1205
+ async getOnlineStatus () {
1206
+ const { Characteristic } = this.#platform
1207
+ const isOnline = this.#deviceModel.status.isOnline
1208
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get online status -> ${isOnline}`)
1209
+ return isOnline
1210
+ ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED
1211
+ : Characteristic.ContactSensorState.CONTACT_DETECTED
1212
+ }
1213
+
1214
+ // ==================== Battery Characteristic Handlers ====================
1215
+
1216
+ /**
1217
+ * Get battery level (0-100) from live status
1218
+ * @returns {Promise<CharacteristicValue>}
1219
+ */
1220
+ async getBatteryLevel () {
1221
+ const battery = this.#deviceModel.status.batteryLevelPercentage
1222
+ const level = Number.isFinite(battery) ? battery : 100
1223
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get battery level -> ${level}`)
1224
+ return level
1225
+ }
1226
+
1227
+ /**
1228
+ * Get charging state from live status
1229
+ * @returns {Promise<CharacteristicValue>}
1230
+ */
1231
+ async getChargingState () {
1232
+ const isCharging = this.#deviceModel.status.isCharging
1233
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get charging state -> ${isCharging}`)
1234
+ return isCharging
1235
+ ? this.#platform.Characteristic.ChargingState.CHARGING
1236
+ : this.#platform.Characteristic.ChargingState.NOT_CHARGING
1237
+ }
1238
+
1239
+ /**
1240
+ * Get low battery status from live status
1241
+ * @returns {Promise<CharacteristicValue>}
1242
+ */
1243
+ async getStatusLowBattery () {
1244
+ const battery = this.#deviceModel.status.batteryLevelPercentage
1245
+ const batteryLevel = Number.isFinite(battery) ? battery : 100
1246
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get low battery -> ${batteryLevel}`)
1247
+ return batteryLevel <= LOW_BATTERY_THRESHOLD
1248
+ ? this.#platform.Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW
1249
+ : this.#platform.Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL
1250
+ }
1251
+
1252
+ // ==================== TemperatureSensor Characteristic Handlers ====================
1253
+
1254
+ /**
1255
+ * Get current temperature from live status
1256
+ * @returns {Promise<CharacteristicValue>}
1257
+ */
1258
+ async getCurrentTemperature () {
1259
+ const temp = this.#deviceModel.status.temperatureCelsius
1260
+
1261
+ // Return a default value if temperature is not available
1262
+ if (temp === null || temp === 'notSupported') {
1263
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get temperature -> unavailable`)
1264
+ return 0
1265
+ }
1266
+
1267
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get temperature -> ${temp}`)
1268
+ return Number(temp)
1269
+ }
1270
+
1271
+ /**
1272
+ * Get temperature sensor fault status
1273
+ * @returns {Promise<CharacteristicValue>}
1274
+ */
1275
+ async getTemperatureSensorFault () {
1276
+ // Report fault if device is offline or temperature is not available
1277
+ const isOffline = !this.#deviceModel.status.isOnline
1278
+ const temp = this.#deviceModel.status.temperatureCelsius
1279
+ const isUnavailable = temp === null || temp === 'notSupported'
1280
+ const isFault = isOffline || isUnavailable
1281
+ const fault = isFault
1282
+ ? this.#platform.Characteristic.StatusFault.GENERAL_FAULT
1283
+ : this.#platform.Characteristic.StatusFault.NO_FAULT
1284
+ this.#log.debug(
1285
+ LOG_PREFIX.ACCESSORY,
1286
+ `[${this.#device.name}] Get temperature sensor fault -> ${isFault} (online=${!isOffline} temp=${temp})`
1287
+ )
1288
+ return fault
1289
+ }
1290
+
1291
+ // ==================== Nightlight Characteristic Handlers ====================
1292
+
1293
+ /**
1294
+ * Helper: Parse hex color (handles both '0xRRGGBB' and '#RRGGBB' formats)
1295
+ * @param {string} hexColor - Hex color string
1296
+ * @returns {string} - Normalized hex without prefix (RRGGBB)
1297
+ */
1298
+ parseHexColor (hexColor) {
1299
+ if (!hexColor) return '000000'
1300
+ // Remove '0x' or '#' prefix
1301
+ return hexColor.replace(/^(0x|#)/, '')
1302
+ }
1303
+
1304
+ /**
1305
+ * Helper: Format hex color to Yoto format (0xRRGGBB)
1306
+ * @param {string} hex - Hex color without prefix (RRGGBB)
1307
+ * @returns {string} - Formatted as '0xRRGGBB'
1308
+ */
1309
+ formatHexColor (hex) {
1310
+ return `0x${hex}`
1311
+ }
1312
+
1313
+ /**
1314
+ * Helper: Check if color is "off" (black or 'off' string)
1315
+ * @param {string} color - Color value
1316
+ * @returns {boolean}
1317
+ */
1318
+ isColorOff (color) {
1319
+ return !color || color === 'off' || color === '0x000000' || color === '#000000' || color === '000000'
1320
+ }
1321
+
1322
+ // ---------- Day Nightlight Handlers ----------
1323
+
1324
+ /**
1325
+ * Get day nightlight on/off state
1326
+ * @returns {Promise<CharacteristicValue>}
1327
+ */
1328
+ async getDayNightlightOn () {
1329
+ const color = this.#deviceModel.config.ambientColour
1330
+ const isOn = !this.isColorOff(color)
1331
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get day nightlight on -> ${isOn} (${color})`)
1332
+ return isOn
1333
+ }
1334
+
1335
+ /**
1336
+ * Set day nightlight on/off state
1337
+ * @param {CharacteristicValue} value
1338
+ */
1339
+ async setDayNightlightOn (value) {
1340
+ try {
1341
+ if (value) {
1342
+ // Turn ON - restore previous color or default to white
1343
+ const colorToSet = this.#lastDayColor || '0xffffff'
1344
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Turning day nightlight ON with color: ${colorToSet}`)
1345
+ await this.#deviceModel.updateConfig({ ambientColour: colorToSet })
1346
+ } else {
1347
+ // Turn OFF - save current color and set to black
1348
+ const currentColor = this.#deviceModel.config.ambientColour
1349
+ if (!this.isColorOff(currentColor)) {
1350
+ this.#lastDayColor = currentColor
1351
+ }
1352
+ this.#log.debug(LOG_PREFIX.ACCESSORY, 'Turning day nightlight OFF')
1353
+ await this.#deviceModel.updateConfig({ ambientColour: '0x000000' })
1354
+ }
1355
+ } catch (error) {
1356
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set day nightlight:`, formatError(error))
1357
+ throw new this.#platform.api.hap.HapStatusError(
1358
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1359
+ )
1360
+ }
1361
+ }
1362
+
1363
+ /**
1364
+ * Get day nightlight brightness (screen brightness)
1365
+ * @returns {Promise<CharacteristicValue>}
1366
+ */
1367
+ async getDayNightlightBrightness () {
1368
+ const config = this.#deviceModel.config
1369
+ const isAuto = config.dayDisplayBrightnessAuto
1370
+ const raw = config.dayDisplayBrightness
1371
+ const brightness = isAuto || raw === null
1372
+ ? 100
1373
+ : Math.max(0, Math.min(Math.round(raw), 100))
1374
+ this.#log.debug(
1375
+ LOG_PREFIX.ACCESSORY,
1376
+ `[${this.#device.name}] Get day nightlight brightness -> ${brightness} (raw=${raw} auto=${isAuto})`
1377
+ )
1378
+ return brightness
1379
+ }
1380
+
1381
+ /**
1382
+ * Set day nightlight brightness (screen brightness)
1383
+ * @param {CharacteristicValue} value
1384
+ */
1385
+ async setDayNightlightBrightness (value) {
1386
+ const rawBrightness = Number(value)
1387
+ if (!Number.isFinite(rawBrightness)) {
1388
+ throw new this.#platform.api.hap.HapStatusError(
1389
+ this.#platform.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST
1390
+ )
1391
+ }
1392
+
1393
+ const brightnessValue = Math.max(0, Math.min(Math.round(rawBrightness), 100))
1394
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Setting day display brightness: ${brightnessValue}`)
1395
+ try {
1396
+ await this.#deviceModel.updateConfig({
1397
+ dayDisplayBrightness: brightnessValue,
1398
+ dayDisplayBrightnessAuto: false
1399
+ })
1400
+ } catch (error) {
1401
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set day brightness:`, formatError(error))
1402
+ throw new this.#platform.api.hap.HapStatusError(
1403
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1404
+ )
1405
+ }
1406
+ }
1407
+
1408
+ /**
1409
+ * Get day nightlight hue
1410
+ * @returns {Promise<CharacteristicValue>}
1411
+ */
1412
+ async getDayNightlightHue () {
1413
+ const color = this.#deviceModel.config.ambientColour
1414
+ if (this.isColorOff(color)) {
1415
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get day nightlight hue -> 0 (off)`)
1416
+ return 0
1417
+ }
1418
+ const hex = this.parseHexColor(color)
1419
+ const [h] = convert.hex.hsv(hex)
1420
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get day nightlight hue -> ${h} (${color})`)
1421
+ return h
1422
+ }
1423
+
1424
+ /**
1425
+ * Set day nightlight hue
1426
+ * @param {CharacteristicValue} value
1427
+ */
1428
+ async setDayNightlightHue (value) {
1429
+ const rawHue = Number(value)
1430
+ if (!Number.isFinite(rawHue)) {
1431
+ throw new this.#platform.api.hap.HapStatusError(
1432
+ this.#platform.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST
1433
+ )
1434
+ }
1435
+ const hue = Math.max(0, Math.min(rawHue, 360))
1436
+
1437
+ // Get current saturation to maintain it
1438
+ const currentColor = this.#deviceModel.config.ambientColour
1439
+ let saturation = 100
1440
+ if (!this.isColorOff(currentColor)) {
1441
+ const hex = this.parseHexColor(currentColor)
1442
+ const [, s] = convert.hex.hsv(hex)
1443
+ saturation = s
1444
+ }
1445
+
1446
+ // Convert HSV to hex (use full value/brightness for color)
1447
+ const newHex = convert.hsv.hex([hue, saturation, 100])
1448
+ const formattedColor = this.formatHexColor(newHex)
1449
+
1450
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Setting day nightlight hue: ${hue}° → ${formattedColor}`)
1451
+ try {
1452
+ await this.#deviceModel.updateConfig({ ambientColour: formattedColor })
1453
+ } catch (error) {
1454
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set day nightlight hue:`, formatError(error))
1455
+ throw new this.#platform.api.hap.HapStatusError(
1456
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1457
+ )
1458
+ }
1459
+ }
1460
+
1461
+ /**
1462
+ * Get day nightlight saturation
1463
+ * @returns {Promise<CharacteristicValue>}
1464
+ */
1465
+ async getDayNightlightSaturation () {
1466
+ const color = this.#deviceModel.config.ambientColour
1467
+ if (this.isColorOff(color)) {
1468
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get day nightlight saturation -> 0 (off)`)
1469
+ return 0
1470
+ }
1471
+ const hex = this.parseHexColor(color)
1472
+ const [, s] = convert.hex.hsv(hex)
1473
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get day nightlight saturation -> ${s} (${color})`)
1474
+ return s
1475
+ }
1476
+
1477
+ /**
1478
+ * Set day nightlight saturation
1479
+ * @param {CharacteristicValue} value
1480
+ */
1481
+ async setDayNightlightSaturation (value) {
1482
+ const rawSaturation = Number(value)
1483
+ if (!Number.isFinite(rawSaturation)) {
1484
+ throw new this.#platform.api.hap.HapStatusError(
1485
+ this.#platform.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST
1486
+ )
1487
+ }
1488
+ const saturation = Math.max(0, Math.min(rawSaturation, 100))
1489
+
1490
+ // Get current hue to maintain it
1491
+ const currentColor = this.#deviceModel.config.ambientColour
1492
+ let hue = 0
1493
+ if (!this.isColorOff(currentColor)) {
1494
+ const hex = this.parseHexColor(currentColor)
1495
+ const [h] = convert.hex.hsv(hex)
1496
+ hue = h
1497
+ }
1498
+
1499
+ // Convert HSV to hex (use full value/brightness for color)
1500
+ const newHex = convert.hsv.hex([hue, saturation, 100])
1501
+ const formattedColor = this.formatHexColor(newHex)
1502
+
1503
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Setting day nightlight saturation: ${saturation}% → ${formattedColor}`)
1504
+ try {
1505
+ await this.#deviceModel.updateConfig({ ambientColour: formattedColor })
1506
+ } catch (error) {
1507
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set day nightlight saturation:`, formatError(error))
1508
+ throw new this.#platform.api.hap.HapStatusError(
1509
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1510
+ )
1511
+ }
1512
+ }
1513
+
1514
+ // ---------- Night Nightlight Handlers ----------
1515
+
1516
+ /**
1517
+ * Get night nightlight on/off state
1518
+ * @returns {Promise<CharacteristicValue>}
1519
+ */
1520
+ async getNightNightlightOn () {
1521
+ const color = this.#deviceModel.config.nightAmbientColour
1522
+ const isOn = !this.isColorOff(color)
1523
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get night nightlight on -> ${isOn} (${color})`)
1524
+ return isOn
1525
+ }
1526
+
1527
+ /**
1528
+ * Set night nightlight on/off state
1529
+ * @param {CharacteristicValue} value
1530
+ */
1531
+ async setNightNightlightOn (value) {
1532
+ try {
1533
+ if (value) {
1534
+ // Turn ON - restore previous color or default to white
1535
+ const colorToSet = this.#lastNightColor || '0xffffff'
1536
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Turning night nightlight ON with color: ${colorToSet}`)
1537
+ await this.#deviceModel.updateConfig({ nightAmbientColour: colorToSet })
1538
+ } else {
1539
+ // Turn OFF - save current color and set to black
1540
+ const currentColor = this.#deviceModel.config.nightAmbientColour
1541
+ if (!this.isColorOff(currentColor)) {
1542
+ this.#lastNightColor = currentColor
1543
+ }
1544
+ this.#log.debug(LOG_PREFIX.ACCESSORY, 'Turning night nightlight OFF')
1545
+ await this.#deviceModel.updateConfig({ nightAmbientColour: '0x000000' })
1546
+ }
1547
+ } catch (error) {
1548
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set night nightlight:`, formatError(error))
1549
+ throw new this.#platform.api.hap.HapStatusError(
1550
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1551
+ )
1552
+ }
1553
+ }
1554
+
1555
+ /**
1556
+ * Get night nightlight brightness (screen brightness)
1557
+ * @returns {Promise<CharacteristicValue>}
1558
+ */
1559
+ async getNightNightlightBrightness () {
1560
+ const config = this.#deviceModel.config
1561
+ const isAuto = config.nightDisplayBrightnessAuto
1562
+ const raw = config.nightDisplayBrightness
1563
+ const brightness = isAuto || raw === null
1564
+ ? 100
1565
+ : Math.max(0, Math.min(Math.round(raw), 100))
1566
+ this.#log.debug(
1567
+ LOG_PREFIX.ACCESSORY,
1568
+ `[${this.#device.name}] Get night nightlight brightness -> ${brightness} (raw=${raw} auto=${isAuto})`
1569
+ )
1570
+ return brightness
1571
+ }
1572
+
1573
+ /**
1574
+ * Set night nightlight brightness (screen brightness)
1575
+ * @param {CharacteristicValue} value
1576
+ */
1577
+ async setNightNightlightBrightness (value) {
1578
+ const rawBrightness = Number(value)
1579
+ if (!Number.isFinite(rawBrightness)) {
1580
+ throw new this.#platform.api.hap.HapStatusError(
1581
+ this.#platform.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST
1582
+ )
1583
+ }
1584
+
1585
+ const brightnessValue = Math.max(0, Math.min(Math.round(rawBrightness), 100))
1586
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Setting night display brightness: ${brightnessValue}`)
1587
+ try {
1588
+ await this.#deviceModel.updateConfig({
1589
+ nightDisplayBrightness: brightnessValue,
1590
+ nightDisplayBrightnessAuto: false
1591
+ })
1592
+ } catch (error) {
1593
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set night brightness:`, formatError(error))
1594
+ throw new this.#platform.api.hap.HapStatusError(
1595
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1596
+ )
1597
+ }
1598
+ }
1599
+
1600
+ /**
1601
+ * Get night nightlight hue
1602
+ * @returns {Promise<CharacteristicValue>}
1603
+ */
1604
+ async getNightNightlightHue () {
1605
+ const color = this.#deviceModel.config.nightAmbientColour
1606
+ if (this.isColorOff(color)) {
1607
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get night nightlight hue -> 0 (off)`)
1608
+ return 0
1609
+ }
1610
+ const hex = this.parseHexColor(color)
1611
+ const [h] = convert.hex.hsv(hex)
1612
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get night nightlight hue -> ${h} (${color})`)
1613
+ return h
1614
+ }
1615
+
1616
+ /**
1617
+ * Set night nightlight hue
1618
+ * @param {CharacteristicValue} value
1619
+ */
1620
+ async setNightNightlightHue (value) {
1621
+ const rawHue = Number(value)
1622
+ if (!Number.isFinite(rawHue)) {
1623
+ throw new this.#platform.api.hap.HapStatusError(
1624
+ this.#platform.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST
1625
+ )
1626
+ }
1627
+ const hue = Math.max(0, Math.min(rawHue, 360))
1628
+
1629
+ // Get current saturation to maintain it
1630
+ const currentColor = this.#deviceModel.config.nightAmbientColour
1631
+ let saturation = 100
1632
+ if (!this.isColorOff(currentColor)) {
1633
+ const hex = this.parseHexColor(currentColor)
1634
+ const [, s] = convert.hex.hsv(hex)
1635
+ saturation = s
1636
+ }
1637
+
1638
+ // Convert HSV to hex (use full value/brightness for color)
1639
+ const newHex = convert.hsv.hex([hue, saturation, 100])
1640
+ const formattedColor = this.formatHexColor(newHex)
1641
+
1642
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Setting night nightlight hue: ${hue}° → ${formattedColor}`)
1643
+ try {
1644
+ await this.#deviceModel.updateConfig({ nightAmbientColour: formattedColor })
1645
+ } catch (error) {
1646
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set night nightlight hue:`, formatError(error))
1647
+ throw new this.#platform.api.hap.HapStatusError(
1648
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1649
+ )
1650
+ }
1651
+ }
1652
+
1653
+ /**
1654
+ * Get night nightlight saturation
1655
+ * @returns {Promise<CharacteristicValue>}
1656
+ */
1657
+ async getNightNightlightSaturation () {
1658
+ const color = this.#deviceModel.config.nightAmbientColour
1659
+ if (this.isColorOff(color)) {
1660
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get night nightlight saturation -> 0 (off)`)
1661
+ return 0
1662
+ }
1663
+ const hex = this.parseHexColor(color)
1664
+ const [, s] = convert.hex.hsv(hex)
1665
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get night nightlight saturation -> ${s} (${color})`)
1666
+ return s
1667
+ }
1668
+
1669
+ /**
1670
+ * Set night nightlight saturation
1671
+ * @param {CharacteristicValue} value
1672
+ */
1673
+ async setNightNightlightSaturation (value) {
1674
+ const rawSaturation = Number(value)
1675
+ if (!Number.isFinite(rawSaturation)) {
1676
+ throw new this.#platform.api.hap.HapStatusError(
1677
+ this.#platform.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST
1678
+ )
1679
+ }
1680
+ const saturation = Math.max(0, Math.min(rawSaturation, 100))
1681
+
1682
+ // Get current hue to maintain it
1683
+ const currentColor = this.#deviceModel.config.nightAmbientColour
1684
+ let hue = 0
1685
+ if (!this.isColorOff(currentColor)) {
1686
+ const hex = this.parseHexColor(currentColor)
1687
+ const [h] = convert.hex.hsv(hex)
1688
+ hue = h
1689
+ }
1690
+
1691
+ // Convert HSV to hex (use full value/brightness for color)
1692
+ const newHex = convert.hsv.hex([hue, saturation, 100])
1693
+ const formattedColor = this.formatHexColor(newHex)
1694
+
1695
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Setting night nightlight saturation: ${saturation}% → ${formattedColor}`)
1696
+ try {
1697
+ await this.#deviceModel.updateConfig({ nightAmbientColour: formattedColor })
1698
+ } catch (error) {
1699
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set night nightlight saturation:`, formatError(error))
1700
+ throw new this.#platform.api.hap.HapStatusError(
1701
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1702
+ )
1703
+ }
1704
+ }
1705
+
1706
+ // ==================== Nightlight Status ContactSensor Getters ====================
1707
+
1708
+ /**
1709
+ * Get nightlight active state
1710
+ * @returns {Promise<CharacteristicValue>}
1711
+ */
1712
+ async getNightlightActive () {
1713
+ const { Characteristic } = this.#platform
1714
+ const status = this.#deviceModel.status
1715
+ const isActive = status.nightlightMode !== 'off'
1716
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get nightlight active -> ${isActive}`)
1717
+ return isActive ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : Characteristic.ContactSensorState.CONTACT_DETECTED
1718
+ }
1719
+
1720
+ /**
1721
+ * Get day nightlight active state
1722
+ * @returns {Promise<CharacteristicValue>}
1723
+ */
1724
+ async getDayNightlightActive () {
1725
+ const { Characteristic } = this.#platform
1726
+ const status = this.#deviceModel.status
1727
+ const isDay = status.dayMode === 'day'
1728
+ const isActive = status.nightlightMode !== 'off'
1729
+ const isShowing = isDay && isActive
1730
+ this.#log.debug(
1731
+ LOG_PREFIX.ACCESSORY,
1732
+ `[${this.#device.name}] Get day nightlight active -> ${isShowing} (day=${isDay} active=${isActive})`
1733
+ )
1734
+ return isShowing ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : Characteristic.ContactSensorState.CONTACT_DETECTED
1735
+ }
1736
+
1737
+ /**
1738
+ * Get night nightlight active state
1739
+ * @returns {Promise<CharacteristicValue>}
1740
+ */
1741
+ async getNightNightlightActive () {
1742
+ const { Characteristic } = this.#platform
1743
+ const status = this.#deviceModel.status
1744
+ const isNight = status.dayMode === 'night'
1745
+ const isActive = status.nightlightMode !== 'off'
1746
+ const isShowing = isNight && isActive
1747
+ this.#log.debug(
1748
+ LOG_PREFIX.ACCESSORY,
1749
+ `[${this.#device.name}] Get night nightlight active -> ${isShowing} (night=${isNight} active=${isActive})`
1750
+ )
1751
+ return isShowing ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : Characteristic.ContactSensorState.CONTACT_DETECTED
1752
+ }
1753
+
1754
+ // ==================== Card Slot ContactSensor Getter ====================
1755
+
1756
+ /**
1757
+ * Get card slot state
1758
+ * @returns {Promise<CharacteristicValue>}
1759
+ */
1760
+ async getCardSlotState () {
1761
+ const { Characteristic } = this.#platform
1762
+ const status = this.#deviceModel.status
1763
+ const hasCard = status.cardInsertionState !== 'none'
1764
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get card slot -> ${hasCard} (${status.cardInsertionState})`)
1765
+ return hasCard ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : Characteristic.ContactSensorState.CONTACT_DETECTED
1766
+ }
1767
+
1768
+ // ==================== Day Mode ContactSensor Getter ====================
1769
+
1770
+ /**
1771
+ * Get day mode status
1772
+ * @returns {Promise<CharacteristicValue>}
1773
+ */
1774
+ async getDayModeStatus () {
1775
+ const { Characteristic } = this.#platform
1776
+ const status = this.#deviceModel.status
1777
+ const isDayMode = status.dayMode === 'day'
1778
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get day mode -> ${isDayMode}`)
1779
+ return isDayMode
1780
+ ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED
1781
+ : Characteristic.ContactSensorState.CONTACT_DETECTED
1782
+ }
1783
+
1784
+ // ==================== Sleep Timer Switch Getter/Setter ====================
1785
+
1786
+ /**
1787
+ * Get sleep timer state
1788
+ * @returns {Promise<CharacteristicValue>}
1789
+ */
1790
+ async getSleepTimerState () {
1791
+ const playback = this.#deviceModel.playback
1792
+ this.#log.debug(
1793
+ LOG_PREFIX.ACCESSORY,
1794
+ `[${this.#device.name}] Get sleep timer -> ${playback.sleepTimerActive ?? false}`
1795
+ )
1796
+ return playback.sleepTimerActive ?? false
1797
+ }
1798
+
1799
+ /**
1800
+ * Set sleep timer state
1801
+ * @param {CharacteristicValue} value
1802
+ */
1803
+ async setSleepTimerState (value) {
1804
+ const enabled = Boolean(value)
1805
+
1806
+ try {
1807
+ if (enabled) {
1808
+ // Turn on sleep timer - default to 30 minutes
1809
+ this.#log.debug(LOG_PREFIX.ACCESSORY, 'Activating sleep timer (30 minutes)')
1810
+ await this.#deviceModel.setSleepTimer(30 * 60)
1811
+ } else {
1812
+ // Turn off sleep timer
1813
+ this.#log.debug(LOG_PREFIX.ACCESSORY, 'Deactivating sleep timer')
1814
+ await this.#deviceModel.setSleepTimer(0)
1815
+ }
1816
+ } catch (error) {
1817
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set sleep timer:`, formatError(error))
1818
+ throw new this.#platform.api.hap.HapStatusError(
1819
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1820
+ )
1821
+ }
1822
+ }
1823
+
1824
+ // ==================== Bluetooth Switch Getter/Setter ====================
1825
+
1826
+ /**
1827
+ * Get Bluetooth state
1828
+ * @returns {Promise<CharacteristicValue>}
1829
+ */
1830
+ async getBluetoothState () {
1831
+ const enabled = this.#deviceModel.config.bluetoothEnabled ?? false
1832
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get Bluetooth -> ${enabled}`)
1833
+ return enabled
1834
+ }
1835
+
1836
+ /**
1837
+ * Set Bluetooth state
1838
+ * @param {CharacteristicValue} value
1839
+ */
1840
+ async setBluetoothState (value) {
1841
+ const enabled = Boolean(value)
1842
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Setting Bluetooth: ${enabled ? 'ON' : 'OFF'}`)
1843
+ try {
1844
+ await this.#deviceModel.updateConfig({ bluetoothEnabled: enabled })
1845
+ } catch (error) {
1846
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set Bluetooth:`, formatError(error))
1847
+ throw new this.#platform.api.hap.HapStatusError(
1848
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1849
+ )
1850
+ }
1851
+ }
1852
+
1853
+ // ==================== Card Control Switch Setter ====================
1854
+
1855
+ /**
1856
+ * Trigger card playback for a configured card control or shortcut.
1857
+ * @param {Service} service
1858
+ * @param {PlayableCard} control
1859
+ * @param {CharacteristicValue} value
1860
+ * @returns {Promise<void>}
1861
+ */
1862
+ async setCardControl (service, control, value) {
1863
+ const { Characteristic } = this.#platform
1864
+ const isOn = Boolean(value)
1865
+ this.#log.debug(
1866
+ LOG_PREFIX.ACCESSORY,
1867
+ `[${this.#device.name}] Set card control: ${control.label} (${control.cardId}) -> ${isOn}`
1868
+ )
1869
+
1870
+ if (!isOn) {
1871
+ service.getCharacteristic(Characteristic.On).updateValue(false)
1872
+ return
1873
+ }
1874
+
1875
+ if (!this.#deviceModel.status.isOnline) {
1876
+ this.#log.warn(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Card control skipped (offline): ${control.label}`)
1877
+ service.getCharacteristic(Characteristic.On).updateValue(false)
1878
+ throw new this.#platform.api.hap.HapStatusError(
1879
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1880
+ )
1881
+ }
1882
+
1883
+ this.#log.debug(
1884
+ LOG_PREFIX.ACCESSORY,
1885
+ `[${this.#device.name}] Play card control: ${control.label} (${control.cardId})`
1886
+ )
1887
+
1888
+ try {
1889
+ await this.#deviceModel.startCard(getCardStartOptions(control))
1890
+ } catch (error) {
1891
+ this.#log.error(
1892
+ LOG_PREFIX.ACCESSORY,
1893
+ `[${this.#device.name}] Failed to play card ${control.cardId}:`,
1894
+ formatError(error)
1895
+ )
1896
+ service.getCharacteristic(Characteristic.On).updateValue(false)
1897
+ throw new this.#platform.api.hap.HapStatusError(
1898
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1899
+ )
1900
+ }
1901
+
1902
+ service.getCharacteristic(Characteristic.On).updateValue(false)
1903
+ }
1904
+
1905
+ // ==================== Volume Limit Lightbulb Getters/Setters ====================
1906
+
1907
+ /**
1908
+ * Get day max volume limit as percentage (mapped from 0-16 steps)
1909
+ * @returns {Promise<CharacteristicValue>}
1910
+ */
1911
+ async getDayMaxVolume () {
1912
+ const limit = this.#deviceModel.config.maxVolumeLimit
1913
+ const steps = Number.isFinite(limit) ? limit : 16
1914
+ const percent = stepsToPercent(steps)
1915
+ this.#log.debug(
1916
+ LOG_PREFIX.ACCESSORY,
1917
+ `[${this.#device.name}] Get day max volume limit rawSteps=${limit} percent=${percent}`
1918
+ )
1919
+ return percent
1920
+ }
1921
+
1922
+ /**
1923
+ * Set day max volume limit as percentage (mapped to 0-16 steps)
1924
+ * @param {CharacteristicValue} value
1925
+ */
1926
+ async setDayMaxVolume (value) {
1927
+ const requestedPercent = typeof value === 'number' ? value : Number(value)
1928
+ if (!Number.isFinite(requestedPercent)) {
1929
+ throw new this.#platform.api.hap.HapStatusError(
1930
+ this.#platform.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST
1931
+ )
1932
+ }
1933
+
1934
+ const normalizedPercent = clampPercent(requestedPercent)
1935
+ const requestedSteps = percentToSteps(normalizedPercent)
1936
+ const limit = clampSteps(requestedSteps)
1937
+ const limitPercent = stepsToPercent(limit)
1938
+ this.#log.debug(
1939
+ LOG_PREFIX.ACCESSORY,
1940
+ `[${this.#device.name}] Set day max volume limit raw=${value} normalizedPercent=${normalizedPercent} requestedSteps=${requestedSteps} -> steps=${limit} percent=${limitPercent}`
1941
+ )
1942
+ try {
1943
+ await this.#deviceModel.updateConfig({ maxVolumeLimit: limit })
1944
+ } catch (error) {
1945
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set day max volume limit:`, formatError(error))
1946
+ throw new this.#platform.api.hap.HapStatusError(
1947
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1948
+ )
1949
+ }
1950
+ }
1951
+
1952
+ /**
1953
+ * Get night max volume limit as percentage (mapped from 0-16 steps)
1954
+ * @returns {Promise<CharacteristicValue>}
1955
+ */
1956
+ async getNightMaxVolume () {
1957
+ const limit = this.#deviceModel.config.nightMaxVolumeLimit
1958
+ const steps = Number.isFinite(limit) ? limit : 10
1959
+ const percent = stepsToPercent(steps)
1960
+ this.#log.debug(
1961
+ LOG_PREFIX.ACCESSORY,
1962
+ `[${this.#device.name}] Get night max volume limit rawSteps=${limit} percent=${percent}`
1963
+ )
1964
+ return percent
1965
+ }
1966
+
1967
+ /**
1968
+ * Set night max volume limit as percentage (mapped to 0-16 steps)
1969
+ * @param {CharacteristicValue} value
1970
+ */
1971
+ async setNightMaxVolume (value) {
1972
+ const requestedPercent = typeof value === 'number' ? value : Number(value)
1973
+ if (!Number.isFinite(requestedPercent)) {
1974
+ throw new this.#platform.api.hap.HapStatusError(
1975
+ this.#platform.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST
1976
+ )
1977
+ }
1978
+
1979
+ const normalizedPercent = clampPercent(requestedPercent)
1980
+ const requestedSteps = percentToSteps(normalizedPercent)
1981
+ const limit = clampSteps(requestedSteps)
1982
+ const limitPercent = stepsToPercent(limit)
1983
+ this.#log.debug(
1984
+ LOG_PREFIX.ACCESSORY,
1985
+ `[${this.#device.name}] Set night max volume limit raw=${value} normalizedPercent=${normalizedPercent} requestedSteps=${requestedSteps} -> steps=${limit} percent=${limitPercent}`
1986
+ )
1987
+ try {
1988
+ await this.#deviceModel.updateConfig({ nightMaxVolumeLimit: limit })
1989
+ } catch (error) {
1990
+ this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set night max volume limit:`, formatError(error))
1991
+ throw new this.#platform.api.hap.HapStatusError(
1992
+ this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1993
+ )
1994
+ }
1995
+ }
1996
+
1997
+ // ==================== Characteristic Update Methods ====================
1998
+
1999
+ /**
2000
+ * Update playback switch characteristic
2001
+ * @param { "playing" | "paused" | "stopped" | "loading" | null} playbackStatus - Playback status
2002
+ */
2003
+ updatePlaybackSwitchCharacteristic (playbackStatus) {
2004
+ if (!this.playbackService) return
2005
+
2006
+ const { Characteristic } = this.#platform
2007
+ const isOn = playbackStatus === 'playing'
2008
+ this.playbackService
2009
+ .getCharacteristic(Characteristic.On)
2010
+ .updateValue(isOn)
2011
+ }
2012
+
2013
+ /**
2014
+ * Update volume characteristic
2015
+ * @param {number} volumeSteps - Volume level (0-16)
2016
+ */
2017
+ updateVolumeCharacteristic (volumeSteps) {
2018
+ if (!this.volumeService) return
2019
+
2020
+ if (volumeSteps > 0) {
2021
+ this.#lastNonZeroVolume = stepsToPercent(volumeSteps)
2022
+ }
2023
+
2024
+ const percent = stepsToPercent(volumeSteps)
2025
+ this.#log.debug(
2026
+ LOG_PREFIX.ACCESSORY,
2027
+ `[${this.#device.name}] Update volume characteristic rawSteps=${volumeSteps} percent=${percent}`
2028
+ )
2029
+
2030
+ const { Characteristic } = this.#platform
2031
+ this.volumeService
2032
+ .getCharacteristic(Characteristic.Brightness)
2033
+ .updateValue(percent)
2034
+ }
2035
+
2036
+ /**
2037
+ * Update mute characteristic
2038
+ * @param {number} volume - Volume level
2039
+ */
2040
+ updateMuteCharacteristic (volume) {
2041
+ if (!this.volumeService) return
2042
+
2043
+ const { Characteristic } = this.#platform
2044
+ this.volumeService
2045
+ .getCharacteristic(Characteristic.On)
2046
+ .updateValue(volume > 0)
2047
+ }
2048
+
2049
+ /**
2050
+ * Update battery level characteristic
2051
+ * @param {number} batteryLevel - Battery level percentage
2052
+ */
2053
+ updateBatteryLevelCharacteristic (batteryLevel) {
2054
+ if (!this.batteryService) return
2055
+
2056
+ const { Characteristic } = this.#platform
2057
+ this.batteryService
2058
+ .getCharacteristic(Characteristic.BatteryLevel)
2059
+ .updateValue(batteryLevel)
2060
+ }
2061
+
2062
+ /**
2063
+ * Update low battery characteristic
2064
+ * @param {number} batteryLevel - Battery level percentage
2065
+ */
2066
+ updateLowBatteryCharacteristic (batteryLevel) {
2067
+ if (!this.batteryService) return
2068
+
2069
+ const { Characteristic } = this.#platform
2070
+ const lowBattery = batteryLevel <= LOW_BATTERY_THRESHOLD
2071
+ ? Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW
2072
+ : Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL
2073
+
2074
+ this.batteryService
2075
+ .getCharacteristic(Characteristic.StatusLowBattery)
2076
+ .updateValue(lowBattery)
2077
+ }
2078
+
2079
+ /**
2080
+ * Update charging state characteristic
2081
+ * @param {boolean} isCharging - Is device charging
2082
+ */
2083
+ updateChargingStateCharacteristic (isCharging) {
2084
+ if (!this.batteryService) return
2085
+
2086
+ const { Characteristic } = this.#platform
2087
+ const chargingState = isCharging
2088
+ ? Characteristic.ChargingState.CHARGING
2089
+ : Characteristic.ChargingState.NOT_CHARGING
2090
+
2091
+ this.batteryService
2092
+ .getCharacteristic(Characteristic.ChargingState)
2093
+ .updateValue(chargingState)
2094
+ }
2095
+
2096
+ /**
2097
+ * Update online status characteristic for all device-state services
2098
+ *
2099
+ * Services that need StatusActive (read device state, unavailable when offline):
2100
+ * - TemperatureSensor (temperature reading)
2101
+ * - ContactSensor (online status, card insertion, day/night mode, nightlight status)
2102
+ *
2103
+ * Services that DON'T need StatusActive (config-based, work offline):
2104
+ * - Lightbulb services (ambient lights - config only)
2105
+ * - Lightbulb services (max volume - config only)
2106
+ * - Switch (Bluetooth - config only)
2107
+ * - StatelessProgrammableSwitch (shortcuts - config only)
2108
+ *
2109
+ * @param {boolean} isOnline - Online status
2110
+ */
2111
+ updateOnlineStatusCharacteristic (isOnline) {
2112
+ const { Characteristic } = this.#platform
2113
+
2114
+ if (this.onlineStatusService) {
2115
+ this.onlineStatusService
2116
+ .getCharacteristic(Characteristic.ContactSensorState)
2117
+ .updateValue(isOnline
2118
+ ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED
2119
+ : Characteristic.ContactSensorState.CONTACT_DETECTED)
2120
+ }
2121
+
2122
+ // Update TemperatureSensor (temperature reading)
2123
+ if (this.temperatureSensorService) {
2124
+ this.temperatureSensorService
2125
+ .getCharacteristic(Characteristic.StatusActive)
2126
+ .updateValue(isOnline)
2127
+ }
2128
+
2129
+ // Update nightlight status ContactSensors (device state)
2130
+ if (this.nightlightActiveService) {
2131
+ this.nightlightActiveService
2132
+ .getCharacteristic(Characteristic.StatusActive)
2133
+ .updateValue(isOnline)
2134
+ }
2135
+ if (this.dayNightlightActiveService) {
2136
+ this.dayNightlightActiveService
2137
+ .getCharacteristic(Characteristic.StatusActive)
2138
+ .updateValue(isOnline)
2139
+ }
2140
+ if (this.nightNightlightActiveService) {
2141
+ this.nightNightlightActiveService
2142
+ .getCharacteristic(Characteristic.StatusActive)
2143
+ .updateValue(isOnline)
2144
+ }
2145
+
2146
+ // Update card slot ContactSensor (device state)
2147
+ if (this.cardSlotService) {
2148
+ this.cardSlotService
2149
+ .getCharacteristic(Characteristic.StatusActive)
2150
+ .updateValue(isOnline)
2151
+ }
2152
+
2153
+ // Update day mode ContactSensor (device state)
2154
+ if (this.dayModeService) {
2155
+ this.dayModeService
2156
+ .getCharacteristic(Characteristic.StatusActive)
2157
+ .updateValue(isOnline)
2158
+ }
2159
+
2160
+ // Note: Config-based services (Nightlight Lightbulbs, Volume Limit Lightbulbs, Bluetooth Switch)
2161
+ // do NOT get StatusActive updated - they work offline since they only read/write config.
2162
+ // Switch services (card controls, shortcuts) have no StatusActive; they fail with an error when offline.
2163
+ }
2164
+
2165
+ /**
2166
+ * Update firmware version characteristic
2167
+ * @param {string} firmwareVersion - Firmware version
2168
+ */
2169
+ updateFirmwareVersionCharacteristic (firmwareVersion) {
2170
+ const { Service, Characteristic } = this.#platform
2171
+ const infoService = this.#accessory.getService(Service.AccessoryInformation)
2172
+ if (!infoService) return
2173
+
2174
+ infoService.setCharacteristic(
2175
+ Characteristic.FirmwareRevision,
2176
+ firmwareVersion
2177
+ )
2178
+ }
2179
+
2180
+ /**
2181
+ * Update nightlight status ContactSensor characteristics
2182
+ */
2183
+ updateNightlightStatusCharacteristics () {
2184
+ const { Characteristic } = this.#platform
2185
+ const status = this.#deviceModel.status
2186
+
2187
+ if (this.nightlightActiveService) {
2188
+ const isActive = status.nightlightMode !== 'off'
2189
+ this.nightlightActiveService
2190
+ .getCharacteristic(Characteristic.ContactSensorState)
2191
+ .updateValue(isActive ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : Characteristic.ContactSensorState.CONTACT_DETECTED)
2192
+ }
2193
+
2194
+ if (this.dayNightlightActiveService) {
2195
+ const isDay = status.dayMode === 'day'
2196
+ const isActive = status.nightlightMode !== 'off'
2197
+ const isShowing = isDay && isActive
2198
+ this.dayNightlightActiveService
2199
+ .getCharacteristic(Characteristic.ContactSensorState)
2200
+ .updateValue(isShowing ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : Characteristic.ContactSensorState.CONTACT_DETECTED)
2201
+ }
2202
+
2203
+ if (this.nightNightlightActiveService) {
2204
+ const isNight = status.dayMode === 'night'
2205
+ const isActive = status.nightlightMode !== 'off'
2206
+ const isShowing = isNight && isActive
2207
+ this.nightNightlightActiveService
2208
+ .getCharacteristic(Characteristic.ContactSensorState)
2209
+ .updateValue(isShowing ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : Characteristic.ContactSensorState.CONTACT_DETECTED)
2210
+ }
2211
+ }
2212
+
2213
+ /**
2214
+ * Update card slot ContactSensor characteristic
2215
+ */
2216
+ updateCardSlotCharacteristic () {
2217
+ if (!this.cardSlotService) {
2218
+ return
2219
+ }
2220
+
2221
+ const { Characteristic } = this.#platform
2222
+ const status = this.#deviceModel.status
2223
+ const hasCard = status.cardInsertionState !== 'none'
2224
+
2225
+ this.cardSlotService
2226
+ .getCharacteristic(Characteristic.ContactSensorState)
2227
+ .updateValue(hasCard ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : Characteristic.ContactSensorState.CONTACT_DETECTED)
2228
+ }
2229
+
2230
+ /**
2231
+ * Update day mode ContactSensor characteristic
2232
+ */
2233
+ updateDayModeCharacteristic () {
2234
+ if (!this.dayModeService) {
2235
+ return
2236
+ }
2237
+
2238
+ const { Characteristic } = this.#platform
2239
+ const status = this.#deviceModel.status
2240
+ const isDayMode = status.dayMode === 'day'
2241
+
2242
+ this.dayModeService
2243
+ .getCharacteristic(Characteristic.ContactSensorState)
2244
+ .updateValue(isDayMode ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : Characteristic.ContactSensorState.CONTACT_DETECTED)
2245
+ }
2246
+
2247
+ /**
2248
+ * Update sleep timer Switch characteristic
2249
+ */
2250
+ updateSleepTimerCharacteristic () {
2251
+ if (!this.sleepTimerService) {
2252
+ return
2253
+ }
2254
+
2255
+ const { Characteristic } = this.#platform
2256
+ const playback = this.#deviceModel.playback
2257
+
2258
+ this.sleepTimerService
2259
+ .getCharacteristic(Characteristic.On)
2260
+ .updateValue(playback.sleepTimerActive ?? false)
2261
+ }
2262
+
2263
+ /**
2264
+ * Update Bluetooth Switch characteristic
2265
+ */
2266
+ updateBluetoothCharacteristic () {
2267
+ if (!this.bluetoothService) {
2268
+ return
2269
+ }
2270
+
2271
+ const { Characteristic } = this.#platform
2272
+ const enabled = this.#deviceModel.config.bluetoothEnabled ?? false
2273
+
2274
+ this.bluetoothService
2275
+ .getCharacteristic(Characteristic.On)
2276
+ .updateValue(enabled)
2277
+ }
2278
+
2279
+ /**
2280
+ * Update volume limit Lightbulb characteristics
2281
+ */
2282
+ updateVolumeLimitCharacteristics () {
2283
+ const config = this.#deviceModel.config
2284
+ const { Characteristic } = this.#platform
2285
+
2286
+ if (this.dayMaxVolumeService) {
2287
+ const limit = Number.isFinite(config.maxVolumeLimit) ? config.maxVolumeLimit : 16
2288
+ const percent = stepsToPercent(limit)
2289
+ this.#log.debug(
2290
+ LOG_PREFIX.ACCESSORY,
2291
+ `[${this.#device.name}] Update day max volume characteristic rawSteps=${limit} percent=${percent}`
2292
+ )
2293
+ this.dayMaxVolumeService
2294
+ .getCharacteristic(Characteristic.Brightness)
2295
+ .updateValue(percent)
2296
+ }
2297
+
2298
+ if (this.nightMaxVolumeService) {
2299
+ const limit = Number.isFinite(config.nightMaxVolumeLimit) ? config.nightMaxVolumeLimit : 10
2300
+ const percent = stepsToPercent(limit)
2301
+ this.#log.debug(
2302
+ LOG_PREFIX.ACCESSORY,
2303
+ `[${this.#device.name}] Update night max volume characteristic rawSteps=${limit} percent=${percent}`
2304
+ )
2305
+ this.nightMaxVolumeService
2306
+ .getCharacteristic(Characteristic.Brightness)
2307
+ .updateValue(percent)
2308
+ }
2309
+ }
2310
+
2311
+ /**
2312
+ * Update temperature characteristic and fault status
2313
+ * @param {string | number | null} temperature - Temperature in Celsius
2314
+ */
2315
+ updateTemperatureCharacteristic (temperature) {
2316
+ if (!this.temperatureSensorService) return
2317
+
2318
+ // Skip if temperature is not available
2319
+ if (temperature === null || temperature === 'notSupported') {
2320
+ return
2321
+ }
2322
+
2323
+ const { Characteristic } = this.#platform
2324
+ const temp = Number(temperature)
2325
+ this.temperatureSensorService
2326
+ .getCharacteristic(Characteristic.CurrentTemperature)
2327
+ .updateValue(temp)
2328
+
2329
+ // Update fault status
2330
+ const fault = Characteristic.StatusFault.NO_FAULT
2331
+ this.temperatureSensorService
2332
+ .getCharacteristic(Characteristic.StatusFault)
2333
+ .updateValue(fault)
2334
+ }
2335
+
2336
+ // ==================== Lifecycle Methods ====================
2337
+
2338
+ /**
2339
+ * Stop accessory - cleanup event listeners
2340
+ * @returns {Promise<void>}
2341
+ */
2342
+ async stop () {
2343
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `Stopping ${this.#device.name}`)
2344
+
2345
+ // Only remove our own listeners; the device model is shared
2346
+ this.#listeners.removeAll()
2347
+
2348
+ // Note: Don't call deviceModel.stop() here - that's handled by YotoAccount
2349
+ }
2350
+ }