homebridge-yoto 0.0.35 → 0.0.36

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.
package/config.schema.cjs CHANGED
@@ -1,3 +1,11 @@
1
+ /** @type {import('./config.schema.json')} */
1
2
  const configSchema = require('./config.schema.json')
2
3
 
4
+ const serviceSchema = configSchema.schema.properties.services.properties
5
+
6
+ /**
7
+ * @typedef {keyof typeof serviceSchema} ServiceSchemaKey
8
+ */
9
+
3
10
  exports.configSchema = configSchema
11
+ exports.serviceSchema = serviceSchema
@@ -44,6 +44,66 @@
44
44
  "x-schema-form": {
45
45
  "hidden": true
46
46
  }
47
+ },
48
+ "services": {
49
+ "title": "Accessory Services",
50
+ "type": "object",
51
+ "properties": {
52
+ "playback": {
53
+ "title": "Playback",
54
+ "type": "boolean",
55
+ "default": true,
56
+ "description": "Expose a playback switch."
57
+ },
58
+ "volume": {
59
+ "title": "Volume",
60
+ "type": "boolean",
61
+ "default": true,
62
+ "description": "Expose volume controls."
63
+ },
64
+ "temperature": {
65
+ "title": "Temperature Sensor",
66
+ "type": "boolean",
67
+ "default": true,
68
+ "description": "Expose temperature sensor when supported."
69
+ },
70
+ "nightlight": {
71
+ "title": "Nightlight",
72
+ "type": "boolean",
73
+ "default": false,
74
+ "description": "Expose day/night nightlight controls and status."
75
+ },
76
+ "cardSlot": {
77
+ "title": "Card Slot",
78
+ "type": "boolean",
79
+ "default": true,
80
+ "description": "Expose card insertion status."
81
+ },
82
+ "nightMode": {
83
+ "title": "Night Mode",
84
+ "type": "boolean",
85
+ "default": true,
86
+ "description": "Expose night mode status."
87
+ },
88
+ "sleepTimer": {
89
+ "title": "Sleep Timer",
90
+ "type": "boolean",
91
+ "default": false,
92
+ "description": "Expose sleep timer switch."
93
+ },
94
+ "bluetooth": {
95
+ "title": "Bluetooth",
96
+ "type": "boolean",
97
+ "default": false,
98
+ "description": "Expose Bluetooth toggle."
99
+ },
100
+ "volumeLimits": {
101
+ "title": "Volume Limits",
102
+ "type": "boolean",
103
+ "default": true,
104
+ "description": "Expose day/night max volume controls."
105
+ }
106
+ }
47
107
  }
48
108
  }
49
109
  },
@@ -62,6 +122,27 @@
62
122
  "accessToken",
63
123
  "refreshToken"
64
124
  ]
125
+ },
126
+ {
127
+ "type": "section",
128
+ "title": "Accessory Services",
129
+ "expandable": true,
130
+ "expanded": false,
131
+ "items": [
132
+ {
133
+ "type": "help",
134
+ "helpvalue": "<p>Select which HomeKit services to expose for each Yoto device.</p>"
135
+ },
136
+ "services.playback",
137
+ "services.volume",
138
+ "services.temperature",
139
+ "services.nightlight",
140
+ "services.cardSlot",
141
+ "services.nightMode",
142
+ "services.sleepTimer",
143
+ "services.bluetooth",
144
+ "services.volumeLimits"
145
+ ]
65
146
  }
66
147
  ]
67
148
  }
@@ -52,8 +52,7 @@ async function initializeUI () {
52
52
  if (retryBtn) retryBtn.addEventListener('click', retryAuth)
53
53
  if (logoutBtn) logoutBtn.addEventListener('click', logout)
54
54
 
55
- // Show schema-based config form below custom UI
56
- homebridge.showSchemaForm()
55
+ homebridge.hideSchemaForm()
57
56
 
58
57
  // Load auth config and check authentication status
59
58
  await loadAuthConfig()
@@ -63,6 +62,17 @@ async function initializeUI () {
63
62
  // Initialize on ready
64
63
  homebridge.addEventListener('ready', initializeUI)
65
64
 
65
+ /**
66
+ * @param {boolean} shouldShow
67
+ */
68
+ function setSchemaFormVisibility (shouldShow) {
69
+ if (shouldShow) {
70
+ homebridge.showSchemaForm()
71
+ } else {
72
+ homebridge.hideSchemaForm()
73
+ }
74
+ }
75
+
66
76
  /**
67
77
  * Show a specific UI section and hide all others
68
78
  * @param {string} sectionToShow - ID of section to show
@@ -90,6 +100,8 @@ function showSection (sectionToShow, options = {}) {
90
100
  const errorMessageEl = document.getElementById('errorMessage')
91
101
  if (errorMessageEl) errorMessageEl.textContent = options.errorMessage
92
102
  }
103
+
104
+ setSchemaFormVisibility(sectionToShow === 'authSuccess')
93
105
  }
94
106
 
95
107
  /**
@@ -298,12 +310,6 @@ function startPolling () {
298
310
  await homebridge.updatePluginConfig(pluginConfig)
299
311
  await homebridge.savePluginConfig()
300
312
 
301
- // Refresh the schema form to show updated token fields
302
- homebridge.hideSchemaForm()
303
- setTimeout(() => {
304
- homebridge.showSchemaForm()
305
- }, 100)
306
-
307
313
  homebridge.toast.success('Authentication successful!')
308
314
  homebridge.toast.info('Please restart the plugin for changes to take effect', 'Restart Required')
309
315
  showAuthSuccess()
package/lib/accessory.js CHANGED
@@ -7,6 +7,7 @@
7
7
  /** @import { YotoDeviceModel } from 'yoto-nodejs-client' */
8
8
  /** @import { YotoDevice } from 'yoto-nodejs-client/lib/api-endpoints/devices.js' */
9
9
  /** @import { YotoAccessoryContext } from './platform.js' */
10
+ /** @import { ServiceSchemaKey } from '../config.schema.cjs' */
10
11
 
11
12
  /**
12
13
  * Device capabilities detected from metadata
@@ -16,6 +17,20 @@
16
17
  * @property {string | undefined} generation - Device generation (e.g., 'gen3')
17
18
  */
18
19
 
20
+ /**
21
+ * Accessory service toggles from config.
22
+ * @typedef {Object} YotoServiceToggles
23
+ * @property {boolean} playback
24
+ * @property {boolean} volume
25
+ * @property {boolean} temperature
26
+ * @property {boolean} nightlight
27
+ * @property {boolean} cardSlot
28
+ * @property {boolean} nightMode
29
+ * @property {boolean} sleepTimer
30
+ * @property {boolean} bluetooth
31
+ * @property {boolean} volumeLimits
32
+ */
33
+
19
34
  import convert from 'color-convert'
20
35
  import {
21
36
  DEFAULT_MANUFACTURER,
@@ -25,6 +40,31 @@ import {
25
40
  } from './constants.js'
26
41
  import { sanitizeName } from './sanitize-name.js'
27
42
  import { syncServiceNames } from './sync-service-names.js'
43
+ import { serviceSchema } from '../config.schema.cjs'
44
+
45
+ // Use syncServiceNames for every visible service so HomeKit labels stay stable.
46
+ // Exceptions: AccessoryInformation (named in platform) and Battery (set Characteristic.Name only).
47
+
48
+ /**
49
+ * @param {unknown} value
50
+ * @param {boolean} fallback
51
+ * @returns {boolean}
52
+ */
53
+ function getBooleanSetting (value, fallback) {
54
+ return typeof value === 'boolean' ? value : fallback
55
+ }
56
+
57
+ /**
58
+ * @param {ServiceSchemaKey} key
59
+ * @returns {boolean}
60
+ */
61
+ function getServiceDefault (key) {
62
+ const entry = serviceSchema[key]
63
+ if (entry?.default !== undefined && typeof entry.default === 'boolean') {
64
+ return entry.default
65
+ }
66
+ return false
67
+ }
28
68
 
29
69
  /**
30
70
  * Yoto Player Accessory Handler
@@ -39,6 +79,7 @@ export class YotoPlayerAccessory {
39
79
  /** @type {Service | undefined} */ playbackService
40
80
  /** @type {Service | undefined} */ volumeService
41
81
  /** @type {Service | undefined} */ batteryService
82
+ /** @type {Service | undefined} */ onlineStatusService
42
83
  /** @type {Service | undefined} */ temperatureSensorService
43
84
  /** @type {Service | undefined} */ dayNightlightService
44
85
  /** @type {Service | undefined} */ nightNightlightService
@@ -77,6 +118,29 @@ export class YotoPlayerAccessory {
77
118
  this.#currentServices = new Set()
78
119
  }
79
120
 
121
+ /**
122
+ * @returns {YotoServiceToggles}
123
+ */
124
+ getServiceToggles () {
125
+ const config = this.#platform.config
126
+ const services = config && typeof config === 'object' ? config['services'] : undefined
127
+ const serviceConfig = typeof services === 'object' && services !== null
128
+ ? /** @type {Record<string, unknown>} */ (services)
129
+ : {}
130
+
131
+ return {
132
+ playback: getBooleanSetting(serviceConfig.playback, getServiceDefault('playback')),
133
+ volume: getBooleanSetting(serviceConfig.volume, getServiceDefault('volume')),
134
+ temperature: getBooleanSetting(serviceConfig.temperature, getServiceDefault('temperature')),
135
+ nightlight: getBooleanSetting(serviceConfig.nightlight, getServiceDefault('nightlight')),
136
+ cardSlot: getBooleanSetting(serviceConfig.cardSlot, getServiceDefault('cardSlot')),
137
+ nightMode: getBooleanSetting(serviceConfig.nightMode, getServiceDefault('nightMode')),
138
+ sleepTimer: getBooleanSetting(serviceConfig.sleepTimer, getServiceDefault('sleepTimer')),
139
+ bluetooth: getBooleanSetting(serviceConfig.bluetooth, getServiceDefault('bluetooth')),
140
+ volumeLimits: getBooleanSetting(serviceConfig.volumeLimits, getServiceDefault('volumeLimits')),
141
+ }
142
+ }
143
+
80
144
  /**
81
145
  * Setup accessory - create services and setup event listeners
82
146
  * @returns {Promise<void>}
@@ -96,25 +160,46 @@ export class YotoPlayerAccessory {
96
160
  this.#currentServices.clear()
97
161
 
98
162
  // 1. Setup services
163
+ const serviceToggles = this.getServiceToggles()
164
+
99
165
  this.setupAccessoryInformation()
100
- this.setupPlaybackServices()
166
+ this.setupOnlineStatusService()
167
+
168
+ if (serviceToggles.playback) {
169
+ this.setupPlaybackSwitchService()
170
+ }
171
+
172
+ if (serviceToggles.volume) {
173
+ this.setupVolumeService()
174
+ }
175
+
101
176
  this.setupBatteryService()
102
177
 
103
178
  // Setup optional services based on device capabilities
104
- if (this.#deviceModel.capabilities.hasTemperatureSensor) {
179
+ if (serviceToggles.temperature && this.#deviceModel.capabilities.hasTemperatureSensor) {
105
180
  this.setupTemperatureSensorService()
106
181
  }
107
182
 
108
- if (this.#deviceModel.capabilities.hasColoredNightlight) {
183
+ if (serviceToggles.nightlight && this.#deviceModel.capabilities.hasColoredNightlight) {
109
184
  this.setupNightlightServices()
110
185
  }
111
186
 
112
187
  // Setup universal services (available on all devices)
113
- this.setupCardSlotService()
114
- this.setupNightModeService()
115
- this.setupSleepTimerService()
116
- this.setupBluetoothService()
117
- this.setupVolumeLimitServices()
188
+ if (serviceToggles.cardSlot) {
189
+ this.setupCardSlotService()
190
+ }
191
+ if (serviceToggles.nightMode) {
192
+ this.setupNightModeService()
193
+ }
194
+ if (serviceToggles.sleepTimer) {
195
+ this.setupSleepTimerService()
196
+ }
197
+ if (serviceToggles.bluetooth) {
198
+ this.setupBluetoothService()
199
+ }
200
+ if (serviceToggles.volumeLimits) {
201
+ this.setupVolumeLimitServices()
202
+ }
118
203
 
119
204
  // Remove any services that aren't in our current set
120
205
  // (except AccessoryInformation which should always be preserved)
@@ -178,15 +263,29 @@ export class YotoPlayerAccessory {
178
263
  }
179
264
 
180
265
  /**
181
- * Setup basic playback services (non-SmartSpeaker)
266
+ * Setup online/offline ContactSensor service (PRIMARY)
182
267
  */
183
- setupPlaybackServices () {
184
- this.setupPlaybackSwitchService()
185
- this.setupVolumeService()
268
+ setupOnlineStatusService () {
269
+ const { Service, Characteristic } = this.#platform
270
+ const serviceName = this.generateServiceName('Online Status')
271
+
272
+ const service = this.#accessory.getServiceById(Service.ContactSensor, 'OnlineStatus') ||
273
+ this.#accessory.addService(Service.ContactSensor, serviceName, 'OnlineStatus')
274
+
275
+ service.setPrimaryService(true)
276
+
277
+ syncServiceNames({ Characteristic, service, name: serviceName })
278
+
279
+ service
280
+ .getCharacteristic(Characteristic.ContactSensorState)
281
+ .onGet(this.getOnlineStatus.bind(this))
282
+
283
+ this.onlineStatusService = service
284
+ this.#currentServices.add(service)
186
285
  }
187
286
 
188
287
  /**
189
- * Setup play/pause Switch service (PRIMARY)
288
+ * Setup play/pause Switch service
190
289
  */
191
290
  setupPlaybackSwitchService () {
192
291
  const { Service, Characteristic } = this.#platform
@@ -195,15 +294,8 @@ export class YotoPlayerAccessory {
195
294
  const service = this.#accessory.getServiceById(Service.Switch, 'Playback') ||
196
295
  this.#accessory.addService(Service.Switch, serviceName, 'Playback')
197
296
 
198
- service.setPrimaryService(true)
199
-
200
297
  syncServiceNames({ Characteristic, service, name: serviceName })
201
298
 
202
- service.addOptionalCharacteristic(Characteristic.StatusActive)
203
- service
204
- .getCharacteristic(Characteristic.StatusActive)
205
- .onGet(this.getStatusActive.bind(this))
206
-
207
299
  service
208
300
  .getCharacteristic(Characteristic.On)
209
301
  .onGet(this.getPlaybackOn.bind(this))
@@ -214,28 +306,23 @@ export class YotoPlayerAccessory {
214
306
  }
215
307
 
216
308
  /**
217
- * Setup Fanv2 service for volume/mute controls (Speaker service isn't shown in Home)
309
+ * Setup Lightbulb service for volume/mute controls (Speaker service isn't shown in Home)
218
310
  */
219
311
  setupVolumeService () {
220
312
  const { Service, Characteristic } = this.#platform
221
313
  const serviceName = this.generateServiceName('Volume')
222
314
 
223
- const service = this.#accessory.getServiceById(Service.Fanv2, 'Volume') ||
224
- this.#accessory.addService(Service.Fanv2, serviceName, 'Volume')
315
+ const service = this.#accessory.getServiceById(Service.Lightbulb, 'Volume') ||
316
+ this.#accessory.addService(Service.Lightbulb, serviceName, 'Volume')
225
317
  syncServiceNames({ Characteristic, service, name: serviceName })
226
318
 
227
- service.addOptionalCharacteristic(Characteristic.StatusActive)
228
- service
229
- .getCharacteristic(Characteristic.StatusActive)
230
- .onGet(this.getStatusActive.bind(this))
231
-
232
319
  service
233
- .getCharacteristic(Characteristic.Active)
234
- .onGet(this.getVolumeActive.bind(this))
235
- .onSet(this.setVolumeActive.bind(this))
320
+ .getCharacteristic(Characteristic.On)
321
+ .onGet(this.getVolumeOn.bind(this))
322
+ .onSet(this.setVolumeOn.bind(this))
236
323
 
237
324
  service
238
- .getCharacteristic(Characteristic.RotationSpeed)
325
+ .getCharacteristic(Characteristic.Brightness)
239
326
  .setProps({
240
327
  minValue: 0,
241
328
  maxValue: 16,
@@ -256,7 +343,7 @@ export class YotoPlayerAccessory {
256
343
  const serviceName = this.generateServiceName('Battery')
257
344
  const service = this.#accessory.getService(Service.Battery) ||
258
345
  this.#accessory.addService(Service.Battery, serviceName)
259
- syncServiceNames({ Characteristic, service, name: serviceName })
346
+ service.setCharacteristic(Characteristic.Name, serviceName)
260
347
 
261
348
  // BatteryLevel (GET only)
262
349
  service.getCharacteristic(Characteristic.BatteryLevel)
@@ -428,18 +515,18 @@ export class YotoPlayerAccessory {
428
515
  }
429
516
 
430
517
  /**
431
- * Setup night mode OccupancySensor service
518
+ * Setup night mode ContactSensor service
432
519
  * Shows if device is in night mode (vs day mode)
433
520
  */
434
521
  setupNightModeService () {
435
522
  const { Service, Characteristic } = this.#platform
436
523
  const serviceName = this.generateServiceName('Night Mode')
437
524
 
438
- const service = this.#accessory.getServiceById(Service.OccupancySensor, 'NightModeStatus') ||
439
- this.#accessory.addService(Service.OccupancySensor, serviceName, 'NightModeStatus')
525
+ const service = this.#accessory.getServiceById(Service.ContactSensor, 'NightModeStatus') ||
526
+ this.#accessory.addService(Service.ContactSensor, serviceName, 'NightModeStatus')
440
527
  syncServiceNames({ Characteristic, service, name: serviceName })
441
528
 
442
- service.getCharacteristic(Characteristic.OccupancyDetected)
529
+ service.getCharacteristic(Characteristic.ContactSensorState)
443
530
  .onGet(this.getNightModeStatus.bind(this))
444
531
 
445
532
  this.nightModeService = service
@@ -487,7 +574,7 @@ export class YotoPlayerAccessory {
487
574
  }
488
575
 
489
576
  /**
490
- * Setup volume limit Fanv2 services
577
+ * Setup volume limit Lightbulb services
491
578
  * Control day and night mode max volume limits
492
579
  */
493
580
  setupVolumeLimitServices () {
@@ -495,16 +582,22 @@ export class YotoPlayerAccessory {
495
582
 
496
583
  // Day Max Volume
497
584
  const dayName = this.generateServiceName('Day Max Volume')
498
- const dayService = this.#accessory.getServiceById(Service.Fanv2, 'DayMaxVolume') ||
499
- this.#accessory.addService(Service.Fanv2, dayName, 'DayMaxVolume')
585
+ const dayService = this.#accessory.getServiceById(Service.Lightbulb, 'DayMaxVolume') ||
586
+ this.#accessory.addService(Service.Lightbulb, dayName, 'DayMaxVolume')
500
587
  syncServiceNames({ Characteristic, service: dayService, name: dayName })
501
588
 
502
589
  dayService
503
- .getCharacteristic(Characteristic.Active)
504
- .onGet(() => Characteristic.Active.ACTIVE)
590
+ .getCharacteristic(Characteristic.On)
591
+ .onGet(() => true)
592
+ .onSet((value) => {
593
+ if (!value) {
594
+ dayService.updateCharacteristic(Characteristic.On, true)
595
+ }
596
+ })
597
+ dayService.setCharacteristic(Characteristic.On, true)
505
598
 
506
599
  dayService
507
- .getCharacteristic(Characteristic.RotationSpeed)
600
+ .getCharacteristic(Characteristic.Brightness)
508
601
  .setProps({ minValue: 0, maxValue: 16, minStep: 1 })
509
602
  .onGet(this.getDayMaxVolume.bind(this))
510
603
  .onSet(this.setDayMaxVolume.bind(this))
@@ -513,16 +606,22 @@ export class YotoPlayerAccessory {
513
606
 
514
607
  // Night Max Volume
515
608
  const nightName = this.generateServiceName('Night Max Volume')
516
- const nightService = this.#accessory.getServiceById(Service.Fanv2, 'NightMaxVolume') ||
517
- this.#accessory.addService(Service.Fanv2, nightName, 'NightMaxVolume')
609
+ const nightService = this.#accessory.getServiceById(Service.Lightbulb, 'NightMaxVolume') ||
610
+ this.#accessory.addService(Service.Lightbulb, nightName, 'NightMaxVolume')
518
611
  syncServiceNames({ Characteristic, service: nightService, name: nightName })
519
612
 
520
613
  nightService
521
- .getCharacteristic(Characteristic.Active)
522
- .onGet(() => Characteristic.Active.ACTIVE)
614
+ .getCharacteristic(Characteristic.On)
615
+ .onGet(() => true)
616
+ .onSet((value) => {
617
+ if (!value) {
618
+ nightService.updateCharacteristic(Characteristic.On, true)
619
+ }
620
+ })
621
+ nightService.setCharacteristic(Characteristic.On, true)
523
622
 
524
623
  nightService
525
- .getCharacteristic(Characteristic.RotationSpeed)
624
+ .getCharacteristic(Characteristic.Brightness)
526
625
  .setProps({ minValue: 0, maxValue: 16, minStep: 1 })
527
626
  .onGet(this.getNightMaxVolume.bind(this))
528
627
  .onSet(this.setNightMaxVolume.bind(this))
@@ -582,7 +681,7 @@ export class YotoPlayerAccessory {
582
681
  break
583
682
 
584
683
  case 'dayMode':
585
- // Update night mode OccupancySensor
684
+ // Update night mode ContactSensor
586
685
  this.updateNightModeCharacteristic()
587
686
  // Update nightlight status ContactSensors (depends on dayMode)
588
687
  if (this.#deviceModel.capabilities.hasColoredNightlight) {
@@ -861,17 +960,13 @@ export class YotoPlayerAccessory {
861
960
  if (this.volumeService) {
862
961
  const { Characteristic } = this.#platform
863
962
 
864
- const active = steps === 0
865
- ? Characteristic.Active.INACTIVE
866
- : Characteristic.Active.ACTIVE
867
-
868
963
  this.volumeService
869
- .getCharacteristic(Characteristic.Active)
870
- .updateValue(active)
964
+ .getCharacteristic(Characteristic.On)
965
+ .updateValue(steps > 0)
871
966
 
872
967
  if (steps !== requestedSteps) {
873
968
  this.volumeService
874
- .getCharacteristic(Characteristic.RotationSpeed)
969
+ .getCharacteristic(Characteristic.Brightness)
875
970
  .updateValue(steps)
876
971
  }
877
972
  }
@@ -884,45 +979,34 @@ export class YotoPlayerAccessory {
884
979
  }
885
980
 
886
981
  /**
887
- * Get volume Active state (derived from volume === 0)
982
+ * Get volume On state (derived from volume > 0)
888
983
  * @returns {Promise<CharacteristicValue>}
889
984
  */
890
- async getVolumeActive () {
891
- const { Characteristic } = this.#platform
892
- return this.#deviceModel.status.volume === 0
893
- ? Characteristic.Active.INACTIVE
894
- : Characteristic.Active.ACTIVE
985
+ async getVolumeOn () {
986
+ return this.#deviceModel.status.volume > 0
895
987
  }
896
988
 
897
989
  /**
898
- * Set volume Active state (mute/unmute)
990
+ * Set volume On state (mute/unmute)
899
991
  * @param {CharacteristicValue} value
900
992
  * @returns {Promise<void>}
901
993
  */
902
- async setVolumeActive (value) {
903
- this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Set volume active:`, value)
994
+ async setVolumeOn (value) {
995
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Set volume on:`, value)
904
996
 
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
- }
997
+ const isOn = Boolean(value)
998
+ const currentVolume = this.#deviceModel.status.volume
912
999
 
913
- if (active === Characteristic.Active.INACTIVE) {
914
- await this.setVolume(0)
1000
+ if (!isOn) {
1001
+ if (currentVolume !== 0) {
1002
+ await this.setVolume(0)
1003
+ }
915
1004
  return
916
1005
  }
917
1006
 
918
- if (active === Characteristic.Active.ACTIVE) {
1007
+ if (currentVolume === 0) {
919
1008
  await this.setVolume(this.#lastNonZeroVolume)
920
- return
921
1009
  }
922
-
923
- throw new this.#platform.api.hap.HapStatusError(
924
- this.#platform.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST
925
- )
926
1010
  }
927
1011
 
928
1012
  /**
@@ -933,6 +1017,17 @@ export class YotoPlayerAccessory {
933
1017
  return this.#deviceModel.status.isOnline
934
1018
  }
935
1019
 
1020
+ /**
1021
+ * Get online status as a ContactSensorState
1022
+ * @returns {Promise<CharacteristicValue>}
1023
+ */
1024
+ async getOnlineStatus () {
1025
+ const { Characteristic } = this.#platform
1026
+ return this.#deviceModel.status.isOnline
1027
+ ? Characteristic.ContactSensorState.CONTACT_DETECTED
1028
+ : Characteristic.ContactSensorState.CONTACT_NOT_DETECTED
1029
+ }
1030
+
936
1031
  // ==================== Battery Characteristic Handlers ====================
937
1032
 
938
1033
  /**
@@ -940,7 +1035,8 @@ export class YotoPlayerAccessory {
940
1035
  * @returns {Promise<CharacteristicValue>}
941
1036
  */
942
1037
  async getBatteryLevel () {
943
- return this.#deviceModel.status.batteryLevelPercentage || 100
1038
+ const battery = this.#deviceModel.status.batteryLevelPercentage
1039
+ return Number.isFinite(battery) ? battery : 100
944
1040
  }
945
1041
 
946
1042
  /**
@@ -959,8 +1055,9 @@ export class YotoPlayerAccessory {
959
1055
  * @returns {Promise<CharacteristicValue>}
960
1056
  */
961
1057
  async getStatusLowBattery () {
962
- const battery = this.#deviceModel.status.batteryLevelPercentage || 100
963
- return battery <= LOW_BATTERY_THRESHOLD
1058
+ const battery = this.#deviceModel.status.batteryLevelPercentage
1059
+ const batteryLevel = Number.isFinite(battery) ? battery : 100
1060
+ return batteryLevel <= LOW_BATTERY_THRESHOLD
964
1061
  ? this.#platform.Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW
965
1062
  : this.#platform.Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL
966
1063
  }
@@ -1077,7 +1174,14 @@ export class YotoPlayerAccessory {
1077
1174
  * @param {CharacteristicValue} value
1078
1175
  */
1079
1176
  async setDayNightlightBrightness (value) {
1080
- const brightnessValue = Math.round(Number(value))
1177
+ const rawBrightness = Number(value)
1178
+ if (!Number.isFinite(rawBrightness)) {
1179
+ throw new this.#platform.api.hap.HapStatusError(
1180
+ this.#platform.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST
1181
+ )
1182
+ }
1183
+
1184
+ const brightnessValue = Math.max(0, Math.min(Math.round(rawBrightness), 100))
1081
1185
  this.#log.debug(LOG_PREFIX.ACCESSORY, `Setting day display brightness: ${brightnessValue}`)
1082
1186
  await this.#deviceModel.updateConfig({
1083
1187
  dayDisplayBrightness: brightnessValue,
@@ -1104,7 +1208,13 @@ export class YotoPlayerAccessory {
1104
1208
  * @param {CharacteristicValue} value
1105
1209
  */
1106
1210
  async setDayNightlightHue (value) {
1107
- const hue = Number(value)
1211
+ const rawHue = Number(value)
1212
+ if (!Number.isFinite(rawHue)) {
1213
+ throw new this.#platform.api.hap.HapStatusError(
1214
+ this.#platform.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST
1215
+ )
1216
+ }
1217
+ const hue = Math.max(0, Math.min(rawHue, 360))
1108
1218
 
1109
1219
  // Get current saturation to maintain it
1110
1220
  const currentColor = this.#deviceModel.config.ambientColour
@@ -1142,7 +1252,13 @@ export class YotoPlayerAccessory {
1142
1252
  * @param {CharacteristicValue} value
1143
1253
  */
1144
1254
  async setDayNightlightSaturation (value) {
1145
- const saturation = Number(value)
1255
+ const rawSaturation = Number(value)
1256
+ if (!Number.isFinite(rawSaturation)) {
1257
+ throw new this.#platform.api.hap.HapStatusError(
1258
+ this.#platform.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST
1259
+ )
1260
+ }
1261
+ const saturation = Math.max(0, Math.min(rawSaturation, 100))
1146
1262
 
1147
1263
  // Get current hue to maintain it
1148
1264
  const currentColor = this.#deviceModel.config.ambientColour
@@ -1210,7 +1326,14 @@ export class YotoPlayerAccessory {
1210
1326
  * @param {CharacteristicValue} value
1211
1327
  */
1212
1328
  async setNightNightlightBrightness (value) {
1213
- const brightnessValue = Math.round(Number(value))
1329
+ const rawBrightness = Number(value)
1330
+ if (!Number.isFinite(rawBrightness)) {
1331
+ throw new this.#platform.api.hap.HapStatusError(
1332
+ this.#platform.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST
1333
+ )
1334
+ }
1335
+
1336
+ const brightnessValue = Math.max(0, Math.min(Math.round(rawBrightness), 100))
1214
1337
  this.#log.debug(LOG_PREFIX.ACCESSORY, `Setting night display brightness: ${brightnessValue}`)
1215
1338
  await this.#deviceModel.updateConfig({
1216
1339
  nightDisplayBrightness: brightnessValue,
@@ -1237,7 +1360,13 @@ export class YotoPlayerAccessory {
1237
1360
  * @param {CharacteristicValue} value
1238
1361
  */
1239
1362
  async setNightNightlightHue (value) {
1240
- const hue = Number(value)
1363
+ const rawHue = Number(value)
1364
+ if (!Number.isFinite(rawHue)) {
1365
+ throw new this.#platform.api.hap.HapStatusError(
1366
+ this.#platform.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST
1367
+ )
1368
+ }
1369
+ const hue = Math.max(0, Math.min(rawHue, 360))
1241
1370
 
1242
1371
  // Get current saturation to maintain it
1243
1372
  const currentColor = this.#deviceModel.config.nightAmbientColour
@@ -1275,7 +1404,13 @@ export class YotoPlayerAccessory {
1275
1404
  * @param {CharacteristicValue} value
1276
1405
  */
1277
1406
  async setNightNightlightSaturation (value) {
1278
- const saturation = Number(value)
1407
+ const rawSaturation = Number(value)
1408
+ if (!Number.isFinite(rawSaturation)) {
1409
+ throw new this.#platform.api.hap.HapStatusError(
1410
+ this.#platform.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST
1411
+ )
1412
+ }
1413
+ const saturation = Math.max(0, Math.min(rawSaturation, 100))
1279
1414
 
1280
1415
  // Get current hue to maintain it
1281
1416
  const currentColor = this.#deviceModel.config.nightAmbientColour
@@ -1346,7 +1481,7 @@ export class YotoPlayerAccessory {
1346
1481
  return hasCard ? Characteristic.ContactSensorState.CONTACT_DETECTED : Characteristic.ContactSensorState.CONTACT_NOT_DETECTED
1347
1482
  }
1348
1483
 
1349
- // ==================== Night Mode OccupancySensor Getter ====================
1484
+ // ==================== Night Mode ContactSensor Getter ====================
1350
1485
 
1351
1486
  /**
1352
1487
  * Get night mode status
@@ -1356,7 +1491,9 @@ export class YotoPlayerAccessory {
1356
1491
  const { Characteristic } = this.#platform
1357
1492
  const status = this.#deviceModel.status
1358
1493
  const isNightMode = status.dayMode === 'night'
1359
- return isNightMode ? Characteristic.OccupancyDetected.OCCUPANCY_DETECTED : Characteristic.OccupancyDetected.OCCUPANCY_NOT_DETECTED
1494
+ return isNightMode
1495
+ ? Characteristic.ContactSensorState.CONTACT_DETECTED
1496
+ : Characteristic.ContactSensorState.CONTACT_NOT_DETECTED
1360
1497
  }
1361
1498
 
1362
1499
  // ==================== Sleep Timer Switch Getter/Setter ====================
@@ -1402,7 +1539,7 @@ export class YotoPlayerAccessory {
1402
1539
  * @returns {Promise<CharacteristicValue>}
1403
1540
  */
1404
1541
  async getBluetoothState () {
1405
- return this.#deviceModel.config.bluetoothEnabled
1542
+ return this.#deviceModel.config.bluetoothEnabled ?? false
1406
1543
  }
1407
1544
 
1408
1545
  /**
@@ -1415,7 +1552,7 @@ export class YotoPlayerAccessory {
1415
1552
  await this.#deviceModel.updateConfig({ bluetoothEnabled: enabled })
1416
1553
  }
1417
1554
 
1418
- // ==================== Volume Limit Fanv2 Getters/Setters ====================
1555
+ // ==================== Volume Limit Lightbulb Getters/Setters ====================
1419
1556
 
1420
1557
  /**
1421
1558
  * Get day max volume limit
@@ -1498,7 +1635,7 @@ export class YotoPlayerAccessory {
1498
1635
  }
1499
1636
 
1500
1637
  this.volumeService
1501
- .getCharacteristic(Characteristic.RotationSpeed)
1638
+ .getCharacteristic(Characteristic.Brightness)
1502
1639
  .updateValue(volumeSteps)
1503
1640
  }
1504
1641
 
@@ -1513,7 +1650,7 @@ export class YotoPlayerAccessory {
1513
1650
  const maxVolumeSteps = Number.isFinite(maxVolume) ? maxVolume : 16
1514
1651
  const clampedMaxVolume = Math.max(0, Math.min(maxVolumeSteps, 16))
1515
1652
  this.volumeService
1516
- .getCharacteristic(Characteristic.RotationSpeed)
1653
+ .getCharacteristic(Characteristic.Brightness)
1517
1654
  .setProps({
1518
1655
  minValue: 0,
1519
1656
  maxValue: clampedMaxVolume,
@@ -1531,12 +1668,9 @@ export class YotoPlayerAccessory {
1531
1668
  if (!this.volumeService) return
1532
1669
 
1533
1670
  const { Characteristic } = this.#platform
1534
- const active = volume === 0
1535
- ? Characteristic.Active.INACTIVE
1536
- : Characteristic.Active.ACTIVE
1537
1671
  this.volumeService
1538
- .getCharacteristic(Characteristic.Active)
1539
- .updateValue(active)
1672
+ .getCharacteristic(Characteristic.On)
1673
+ .updateValue(volume > 0)
1540
1674
  }
1541
1675
 
1542
1676
  /**
@@ -1590,17 +1724,12 @@ export class YotoPlayerAccessory {
1590
1724
  * Update online status characteristic for all device-state services
1591
1725
  *
1592
1726
  * 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
1727
  * - 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
1728
+ * - ContactSensor (online status, card insertion, day/night mode, nightlight status)
1600
1729
  *
1601
1730
  * Services that DON'T need StatusActive (config-based, work offline):
1602
1731
  * - Lightbulb services (ambient lights - config only)
1603
- * - Fanv2 services (max volume - config only)
1732
+ * - Lightbulb services (max volume - config only)
1604
1733
  * - Switch (Bluetooth - config only)
1605
1734
  * - StatelessProgrammableSwitch (shortcuts - config only)
1606
1735
  *
@@ -1609,26 +1738,14 @@ export class YotoPlayerAccessory {
1609
1738
  updateOnlineStatusCharacteristic (isOnline) {
1610
1739
  const { Characteristic } = this.#platform
1611
1740
 
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)
1741
+ if (this.onlineStatusService) {
1742
+ this.onlineStatusService
1743
+ .getCharacteristic(Characteristic.ContactSensorState)
1744
+ .updateValue(isOnline
1745
+ ? Characteristic.ContactSensorState.CONTACT_DETECTED
1746
+ : Characteristic.ContactSensorState.CONTACT_NOT_DETECTED)
1622
1747
  }
1623
1748
 
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
1749
  // Update TemperatureSensor (temperature reading)
1633
1750
  if (this.temperatureSensorService) {
1634
1751
  this.temperatureSensorService
@@ -1660,21 +1777,14 @@ export class YotoPlayerAccessory {
1660
1777
  .updateValue(isOnline)
1661
1778
  }
1662
1779
 
1663
- // Update night mode OccupancySensor (device state)
1780
+ // Update night mode ContactSensor (device state)
1664
1781
  if (this.nightModeService) {
1665
1782
  this.nightModeService
1666
1783
  .getCharacteristic(Characteristic.StatusActive)
1667
1784
  .updateValue(isOnline)
1668
1785
  }
1669
1786
 
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)
1787
+ // Note: Config-based services (Nightlight Lightbulbs, Volume Limit Lightbulbs, Bluetooth Switch, Shortcuts)
1678
1788
  // do NOT get StatusActive updated - they work offline since they only read/write config
1679
1789
 
1680
1790
  // TODO: Add shortcut services when implemented
@@ -1753,7 +1863,7 @@ export class YotoPlayerAccessory {
1753
1863
  }
1754
1864
 
1755
1865
  /**
1756
- * Update night mode OccupancySensor characteristic
1866
+ * Update night mode ContactSensor characteristic
1757
1867
  */
1758
1868
  updateNightModeCharacteristic () {
1759
1869
  if (!this.nightModeService) {
@@ -1765,8 +1875,8 @@ export class YotoPlayerAccessory {
1765
1875
  const isNightMode = status.dayMode === 'night'
1766
1876
 
1767
1877
  this.nightModeService
1768
- .getCharacteristic(Characteristic.OccupancyDetected)
1769
- .updateValue(isNightMode ? Characteristic.OccupancyDetected.OCCUPANCY_DETECTED : Characteristic.OccupancyDetected.OCCUPANCY_NOT_DETECTED)
1878
+ .getCharacteristic(Characteristic.ContactSensorState)
1879
+ .updateValue(isNightMode ? Characteristic.ContactSensorState.CONTACT_DETECTED : Characteristic.ContactSensorState.CONTACT_NOT_DETECTED)
1770
1880
  }
1771
1881
 
1772
1882
  /**
@@ -1782,7 +1892,7 @@ export class YotoPlayerAccessory {
1782
1892
 
1783
1893
  this.sleepTimerService
1784
1894
  .getCharacteristic(Characteristic.On)
1785
- .updateValue(playback.sleepTimerActive)
1895
+ .updateValue(playback.sleepTimerActive ?? false)
1786
1896
  }
1787
1897
 
1788
1898
  /**
@@ -1794,7 +1904,7 @@ export class YotoPlayerAccessory {
1794
1904
  }
1795
1905
 
1796
1906
  const { Characteristic } = this.#platform
1797
- const enabled = this.#deviceModel.config.bluetoothEnabled
1907
+ const enabled = this.#deviceModel.config.bluetoothEnabled ?? false
1798
1908
 
1799
1909
  this.bluetoothService
1800
1910
  .getCharacteristic(Characteristic.On)
@@ -1802,7 +1912,7 @@ export class YotoPlayerAccessory {
1802
1912
  }
1803
1913
 
1804
1914
  /**
1805
- * Update volume limit Fanv2 characteristics
1915
+ * Update volume limit Lightbulb characteristics
1806
1916
  */
1807
1917
  updateVolumeLimitCharacteristics () {
1808
1918
  const config = this.#deviceModel.config
@@ -1811,14 +1921,14 @@ export class YotoPlayerAccessory {
1811
1921
  if (this.dayMaxVolumeService) {
1812
1922
  const limit = Number.isFinite(config.maxVolumeLimit) ? config.maxVolumeLimit : 16
1813
1923
  this.dayMaxVolumeService
1814
- .getCharacteristic(Characteristic.RotationSpeed)
1924
+ .getCharacteristic(Characteristic.Brightness)
1815
1925
  .updateValue(limit)
1816
1926
  }
1817
1927
 
1818
1928
  if (this.nightMaxVolumeService) {
1819
1929
  const limit = Number.isFinite(config.nightMaxVolumeLimit) ? config.nightMaxVolumeLimit : 10
1820
1930
  this.nightMaxVolumeService
1821
- .getCharacteristic(Characteristic.RotationSpeed)
1931
+ .getCharacteristic(Characteristic.Brightness)
1822
1932
  .updateValue(limit)
1823
1933
  }
1824
1934
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-yoto",
3
3
  "description": "Control your Yoto players through Apple HomeKit with real-time MQTT updates",
4
- "version": "0.0.35",
4
+ "version": "0.0.36",
5
5
  "author": "Bret Comnes <bcomnes@gmail.com> (https://bret.io)",
6
6
  "bugs": {
7
7
  "url": "https://github.com/bcomnes/homebridge-yoto/issues"