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