@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.
- package/LICENSE +22 -0
- package/README.md +131 -0
- package/config.schema.cjs +11 -0
- package/config.schema.json +231 -0
- package/homebridge-ui/public/client.js +339 -0
- package/homebridge-ui/public/index.html +125 -0
- package/homebridge-ui/server.js +247 -0
- package/index.js +16 -0
- package/lib/accessory.js +2350 -0
- package/lib/card-control-accessory.js +212 -0
- package/lib/card-controls.js +75 -0
- package/lib/platform.js +1019 -0
- package/lib/service-config.js +47 -0
- package/lib/settings.js +56 -0
- package/lib/shortcuts.js +131 -0
- package/lib/speaker-accessory.js +551 -0
- package/lib/sync-service-names.js +39 -0
- package/lib/television-accessory.js +987 -0
- package/lib/utils/error-format.js +29 -0
- package/lib/utils/get-boolean-setting.js +8 -0
- package/lib/utils/get-trimmed-string.js +7 -0
- package/lib/utils/listener-group.js +45 -0
- package/lib/utils/oauth.js +70 -0
- package/lib/utils/sanitize-name.js +49 -0
- package/lib/utils/set-device-volume.js +22 -0
- package/lib/utils/status-scope-fallback.js +48 -0
- package/lib/utils/token-config.js +59 -0
- package/lib/utils/volume.js +78 -0
- package/package.json +76 -0
package/lib/accessory.js
ADDED
|
@@ -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
|
+
}
|