• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

homebridge / HAP-NodeJS / 17775229178

16 Sep 2025 06:21PM UTC coverage: 63.437%. Remained the same
17775229178

push

github

bwp91
v2.0.2

1743 of 3286 branches covered (53.04%)

Branch coverage included in aggregate %.

6250 of 9314 relevant lines covered (67.1%)

312.83 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

17.0
/src/lib/controller/AdaptiveLightingController.ts
1
import assert from "assert";
18✔
2
import { HAPStatus } from "../HAPServer";
3
import { ColorUtils } from "../util/color-utils";
18✔
4
import { HapStatusError } from "../util/hapStatusError";
18✔
5
import { epochMillisFromMillisSince2001_01_01Buffer } from "../util/time";
18✔
6
import * as uuid from "../util/uuid";
18✔
7
import createDebug from "debug";
18✔
8
import { EventEmitter } from "events";
18✔
9
import { CharacteristicValue } from "../../types";
10
import {
18✔
11
  ChangeReason,
12
  Characteristic,
13
  CharacteristicChange,
14
  CharacteristicEventTypes,
15
  CharacteristicOperationContext,
16
} from "../Characteristic";
17
import {
18
  Brightness,
19
  CharacteristicValueActiveTransitionCount,
20
  CharacteristicValueTransitionControl,
21
  ColorTemperature,
22
  Hue,
23
  Lightbulb,
24
  Saturation,
25
  SupportedCharacteristicValueTransitionConfiguration,
26
} from "../definitions";
27
import * as tlv from "../util/tlv";
18✔
28
import {
29
  ControllerIdentifier,
30
  ControllerServiceMap,
31
  DefaultControllerType,
32
  SerializableController,
33
  StateChangeDelegate,
34
} from "./Controller";
35

36
const debug = createDebug("HAP-NodeJS:Controller:TransitionControl");
18✔
37

38
const enum SupportedCharacteristicValueTransitionConfigurationsTypes {
18✔
39
  SUPPORTED_TRANSITION_CONFIGURATION = 0x01,
18✔
40
}
41

42
const enum SupportedValueTransitionConfigurationTypes {
18✔
43
  CHARACTERISTIC_IID = 0x01,
18✔
44
  TRANSITION_TYPE = 0x02, // assumption
18✔
45
}
46

47
const enum TransitionType {
18✔
48
  BRIGHTNESS = 0x01, // uncertain
18✔
49
  COLOR_TEMPERATURE = 0x02,
18✔
50
}
51

52

53
const enum TransitionControlTypes {
18✔
54
  READ_CURRENT_VALUE_TRANSITION_CONFIGURATION = 0x01, // could probably a list of ValueTransitionConfigurationTypes
18✔
55
  UPDATE_VALUE_TRANSITION_CONFIGURATION = 0x02,
18✔
56
}
57

58
const enum ReadValueTransitionConfiguration {
18✔
59
  CHARACTERISTIC_IID = 0x01,
18✔
60
}
61

62
const enum UpdateValueTransitionConfigurationsTypes {
18✔
63
  VALUE_TRANSITION_CONFIGURATION = 0x01, // this type could be a tlv8 list
18✔
64
}
65

66
const enum ValueTransitionConfigurationTypes {
18✔
67
  // noinspection JSUnusedGlobalSymbols
68
  CHARACTERISTIC_IID = 0x01, // 1 byte
18✔
69
  TRANSITION_PARAMETERS = 0x02,
18✔
70
  UNKNOWN_3 = 0x03, // sent with value = 1 (1 byte)
18✔
71
  UNKNOWN_4 = 0x04, // not sent yet by anyone
18✔
72
  TRANSITION_CURVE_CONFIGURATION = 0x05,
18✔
73
  UPDATE_INTERVAL = 0x06, // 16 bit uint
18✔
74
  UNKNOWN_7 = 0x07, // not sent yet by anyone
18✔
75
  NOTIFY_INTERVAL_THRESHOLD = 0x08, // 32 bit uint
18✔
76
}
77

78
const enum ValueTransitionParametersTypes {
18✔
79
  TRANSITION_ID = 0x01, // 16 bytes
18✔
80
  START_TIME = 0x02, // 8 bytes the start time for the provided schedule, millis since 2001/01/01 00:00:000
18✔
81
  UNKNOWN_3 = 0x03, // 8 bytes, id or something (same for multiple writes)
18✔
82
}
83

84
const enum TransitionCurveConfigurationTypes {
18✔
85
  TRANSITION_ENTRY = 0x01,
18✔
86
  ADJUSTMENT_CHARACTERISTIC_IID = 0x02,
18✔
87
  ADJUSTMENT_MULTIPLIER_RANGE = 0x03,
18✔
88
}
89

90
const enum TransitionEntryTypes {
18✔
91
  ADJUSTMENT_FACTOR = 0x01,
18✔
92
  VALUE = 0x02,
18✔
93
  TRANSITION_OFFSET = 0x03, // the time in milliseconds from the previous transition, interpolation happens here
18✔
94
  DURATION = 0x04, // optional, default 0, sets how long the previous value will stay the same (non interpolation time section)
18✔
95
}
96

97
const enum TransitionAdjustmentMultiplierRange {
18✔
98
  MINIMUM_ADJUSTMENT_MULTIPLIER = 0x01, // brightness 10
18✔
99
  MAXIMUM_ADJUSTMENT_MULTIPLIER = 0x02, // brightness 100
18✔
100
}
101

102
const enum ValueTransitionConfigurationResponseTypes { // read format for control point
18✔
103
  VALUE_CONFIGURATION_STATUS = 0x01,
18✔
104
}
105

106
const enum ValueTransitionConfigurationStatusTypes {
18✔
107
  CHARACTERISTIC_IID = 0x01,
18✔
108
  TRANSITION_PARAMETERS = 0x02,
18✔
109
  TIME_SINCE_START = 0x03, // milliseconds since start of transition
18✔
110
}
111

112
interface AdaptiveLightingCharacteristicContext extends CharacteristicOperationContext {
113
  controller: AdaptiveLightingController;
114
}
115

116
// eslint-disable-next-line @typescript-eslint/no-explicit-any
117
function isAdaptiveLightingContext(context: any): context is AdaptiveLightingCharacteristicContext {
118
  return context && "controller" in context;
×
119
}
120

121
interface SavedLastTransitionPointInfo {
122
  curveIndex: number;
123
  lowerBoundTimeOffset: number;
124
}
125

126
/**
127
 * @group Adaptive Lighting
128
 */
129
export interface ActiveAdaptiveLightingTransition {
130
  /**
131
   * The instance id for the characteristic for which this transition applies to (aka the ColorTemperature characteristic).
132
   */
133
  iid: number;
134

135
  /**
136
   * Start of the transition in epoch time millis (as sent from the HomeKit controller).
137
   * Additionally see {@link timeMillisOffset}.
138
   */
139
  transitionStartMillis: number;
140
  /**
141
   * It is not necessarily given, that we have the same time (or rather the correct time) as the HomeKit controller
142
   * who set up the transition schedule.
143
   * Thus we record the delta between our current time and the the time sent with the setup request.
144
   * <code>timeMillisOffset</code> is defined as <code>Date.now() - transitionStartMillis;</code>.
145
   * So in the case were we actually have a correct local time, it most likely will be positive (due to network latency).
146
   * But of course it can also be negative.
147
   */
148
  timeMillisOffset: number;
149

150
  /**
151
   * Value is the same for ALL control write requests I have seen (even on other homes).
152
   * @private
153
   */
154
  transitionId: string;
155
  /**
156
   * Start of transition in milliseconds from 2001-01-01 00:00:00; unsigned 64 bit LE integer
157
   * @private as it is a 64 bit integer, we just store the buffer to not have the struggle to encode/decode 64 bit int in JavaScript
158
   */
159
  transitionStartBuffer: string;
160
  /**
161
   * Hex string of 8 bytes. Some kind of id (?). Sometimes it isn't supplied. Don't know the use for that.
162
   * @private
163
   */
164
  id3?: string;
165

166
  transitionCurve: AdaptiveLightingTransitionCurveEntry[];
167

168
  brightnessCharacteristicIID: number;
169
  brightnessAdjustmentRange: BrightnessAdjustmentMultiplierRange;
170

171
  /**
172
   * Interval in milliseconds specifies how often the accessory should update the color temperature (internally).
173
   * Typically this is 60000 aka 60 seconds aka 1 minute.
174
   * Note {@link notifyIntervalThreshold}
175
   */
176
  updateInterval: number,
177
  /**
178
   * Defines the interval in milliseconds on how often the accessory may send even notifications
179
   * to subscribed HomeKit controllers (aka call {@link Characteristic.updateValue}.
180
   * Typically this is 600000 aka 600 seconds aka 10 minutes or 300000 aka 300 seconds aka 5 minutes.
181
   */
182
  notifyIntervalThreshold: number;
183
}
184

185
/**
186
 * @group Adaptive Lighting
187
 */
188
export interface AdaptiveLightingTransitionPoint {
189
  /**
190
   * This is the time offset from the transition start to the {@link lowerBound}.
191
   */
192
  lowerBoundTimeOffset: number;
193

194

195
  transitionOffset: number;
196

197
  lowerBound: AdaptiveLightingTransitionCurveEntry;
198
  upperBound: AdaptiveLightingTransitionCurveEntry;
199
}
200

201
/**
202
 * @group Adaptive Lighting
203
 */
204
export interface AdaptiveLightingTransitionCurveEntry {
205
  /**
206
   * The color temperature in mired.
207
   */
208
  temperature: number,
209
  /**
210
   * The color temperature actually set to the color temperature characteristic is dependent
211
   * on the current brightness value of the lightbulb.
212
   * This means you will always need to query the current brightness when updating the color temperature
213
   * for the next transition step.
214
   * Additionally you will also need to correct the color temperature when the end user changes the
215
   * brightness of the Lightbulb.
216
   *
217
   * The brightnessAdjustmentFactor is always a negative floating point value.
218
   *
219
   * To calculate the resulting color temperature you will need to do the following.
220
   *
221
   * In short: temperature + brightnessAdjustmentFactor * currentBrightness
222
   *
223
   * Complete example:
224
   * ```js
225
   * const temperature = ...; // next transition value, the above property
226
   * // below query the current brightness while staying the the min/max brightness range (typically between 10-100 percent)
227
   * const currentBrightness = Math.max(minBrightnessValue, Math.min(maxBrightnessValue, CHARACTERISTIC_BRIGHTNESS_VALUE));
228
   *
229
   * // as both temperature and brightnessAdjustmentFactor are floating point values it is advised to round to the next integer
230
   * const resultTemperature = Math.round(temperature + brightnessAdjustmentFactor * currentBrightness);
231
   * ```
232
   */
233
  brightnessAdjustmentFactor: number;
234
  /**
235
   * The duration in milliseconds this exact temperature value stays the same.
236
   * When we transition to to the temperature value represented by this entry, it stays for the specified
237
   * duration on the exact same value (with respect to brightness adjustment) until we transition
238
   * to the next entry (see {@link transitionTime}).
239
   */
240
  duration?: number;
241
  /**
242
   * The time in milliseconds the color temperature should transition from the previous
243
   * entry to this one.
244
   * For example if we got the two values A and B, with A.temperature = 300 and B.temperature = 400 and
245
   * for the current time we are at temperature value 300. Then we need to transition smoothly
246
   * within the B.transitionTime to the B.temperature value.
247
   * If this is the first entry in the Curve (this value is probably zero) and is the offset to the transitionStartMillis
248
   * (the Date/Time were this transition curve was set up).
249
   */
250
  transitionTime: number;
251
}
252

253
/**
254
 * @group Adaptive Lighting
255
 */
256
export interface BrightnessAdjustmentMultiplierRange {
257
  minBrightnessValue: number;
258
  maxBrightnessValue: number;
259
}
260

261
/**
262
 * @group Adaptive Lighting
263
 */
264
export interface AdaptiveLightingOptions {
265
  /**
266
   * Defines how the controller will operate.
267
   * You can choose between automatic and manual mode.
268
   * See {@link AdaptiveLightingControllerMode}.
269
   */
270
  controllerMode?: AdaptiveLightingControllerMode,
271
  /**
272
   * Defines a custom temperature adjustment factor.
273
   *
274
   * This can be used to define a linear deviation from the HomeKit Controller defined
275
   * ColorTemperature schedule.
276
   *
277
   * For example supplying a value of `-10` will reduce the ColorTemperature, which is
278
   * calculated from the transition schedule, by 10 mired for every change.
279
   */
280
  customTemperatureAdjustment?: number,
281
}
282

283
/**
284
 * Defines in which mode the {@link AdaptiveLightingController} will operate in.
285
 * @group Adaptive Lighting
286
 */
287
export const enum AdaptiveLightingControllerMode {
18✔
288
  /**
289
   * In automatic mode pretty much everything from setup to transition scheduling is done by the controller.
290
   */
291
  AUTOMATIC = 1,
18✔
292
  /**
293
   * In manual mode setup is done by the controller but the actual transition must be done by the user.
294
   * This is useful for lights which natively support transitions.
295
   */
296
  MANUAL = 2,
18✔
297
}
298

299
/**
300
 * @group Adaptive Lighting
301
 */
302
export const enum AdaptiveLightingControllerEvents {
18✔
303
  /**
304
   * This event is called once a HomeKit controller enables Adaptive Lighting
305
   * or a HomeHub sends an updated transition schedule for the next 24 hours.
306
   * This is also called on startup when AdaptiveLighting was previously enabled.
307
   */
308
  UPDATE = "update",
18✔
309
  /**
310
   * In yet unknown circumstances HomeKit may also send a dedicated disable command
311
   * via the control point characteristic. You may want to handle that in manual mode as well.
312
   * The current transition will still be associated with the controller object when this event is called.
313
   */
314
  DISABLED = "disable",
18✔
315
}
316

317
/**
318
 * @group Adaptive Lighting
319
 * see {@link ActiveAdaptiveLightingTransition}.
320
 */
321
export interface AdaptiveLightingControllerUpdate {
322
  transitionStartMillis: number;
323
  timeMillisOffset: number;
324
  transitionCurve: AdaptiveLightingTransitionCurveEntry[];
325
  brightnessAdjustmentRange: BrightnessAdjustmentMultiplierRange;
326
  updateInterval: number,
327
  notifyIntervalThreshold: number;
328
}
329

330
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
331
export declare interface AdaptiveLightingController {
332
  /**
333
   * See {@link AdaptiveLightingControllerEvents.UPDATE}
334
   * Also see {@link AdaptiveLightingControllerUpdate}
335
   *
336
   * @param event
337
   * @param listener
338
   */
339
  on(event: "update", listener: (update: AdaptiveLightingControllerUpdate) => void): this;
340
  /**
341
   * See {@link AdaptiveLightingControllerEvents.DISABLED}
342
   *
343
   * @param event
344
   * @param listener
345
   */
346
  on(event: "disable", listener: () => void): this;
347

348
  /**
349
   * See {@link AdaptiveLightingControllerUpdate}
350
   */
351
  emit(event: "update", update: AdaptiveLightingControllerUpdate): boolean;
352
  emit(event: "disable"): boolean;
353
}
354

355
/**
356
 * @group Adaptive Lighting
357
 */
358
export interface SerializedAdaptiveLightingControllerState {
359
  activeTransition: ActiveAdaptiveLightingTransition;
360
}
361

362
/**
363
 * This class allows adding Adaptive Lighting support to Lightbulb services.
364
 * The Lightbulb service MUST have the {@link Characteristic.ColorTemperature} characteristic AND
365
 * the {@link Characteristic.Brightness} characteristic added.
366
 * The light may also expose {@link Characteristic.Hue} and {@link Characteristic.Saturation} characteristics
367
 * (though additional work is required to keep them in sync with the color temperature characteristic. see below)
368
 *
369
 * How Adaptive Lighting works:
370
 *  When enabling AdaptiveLighting the iDevice will send a transition schedule for the next 24 hours.
371
 *  This schedule will be renewed all 24 hours by a HomeHub in your home
372
 *  (updating the schedule according to your current day/night situation).
373
 *  Once enabled the lightbulb will execute the provided transitions. The color temperature value set is always
374
 *  dependent on the current brightness value. Meaning brighter light will be colder and darker light will be warmer.
375
 *  HomeKit considers Adaptive Lighting to be disabled as soon a write happens to either the
376
 *  Hue/Saturation or the ColorTemperature characteristics.
377
 *  The AdaptiveLighting state must persist across reboots.
378
 *
379
 * The AdaptiveLightingController can be operated in two modes: {@link AdaptiveLightingControllerMode.AUTOMATIC} and
380
 * {@link AdaptiveLightingControllerMode.MANUAL} with AUTOMATIC being the default.
381
 * The goal would be that the color transition is done DIRECTLY on the light itself, thus not creating any
382
 * additional/heavy traffic on the network.
383
 * So if your light hardware/API supports transitions please go the extra mile and use MANUAL mode.
384
 *
385
 *
386
 *
387
 * Below is an overview what you need to or consider when enabling AdaptiveLighting (categorized by mode).
388
 * The {@link AdaptiveLightingControllerMode} can be defined with the second constructor argument.
389
 *
390
 * <b>AUTOMATIC (Default mode):</b>
391
 *
392
 *  This is the easiest mode to setup and needs less to no work form your side for AdaptiveLighting to work.
393
 *  The AdaptiveLightingController will go through setup procedure with HomeKit and automatically update
394
 *  the color temperature characteristic base on the current transition schedule.
395
 *  It is also adjusting the color temperature when a write to the brightness characteristic happens.
396
 *  Additionally, it will also handle turning off AdaptiveLighting, when it detects a write happening to the
397
 *  ColorTemperature, Hue or Saturation characteristic (though it can only detect writes coming from HomeKit and
398
 *  can't detect changes done to the physical devices directly! See below).
399
 *
400
 *  So what do you need to consider in automatic mode:
401
 *   - Brightness and ColorTemperature characteristics MUST be set up. Hue and Saturation may be added for color support.
402
 *   - Color temperature will be updated all 60 seconds by calling the SET handler of the ColorTemperature characteristic.
403
 *    So every transition behaves like a regular write to the ColorTemperature characteristic.
404
 *   - Every transition step is dependent on the current brightness value. Try to keep the internal cache updated
405
 *    as the controller won't call the GET handler every 60 seconds.
406
 *    (The cached brightness value is updated on SET/GET operations or by manually calling {@link Characteristic.updateValue}
407
 *    on the brightness characteristic).
408
 *   - Detecting changes on the lightbulb side:
409
 *    Any manual change to ColorTemperature or Hue/Saturation is considered as a signal to turn AdaptiveLighting off.
410
 *    In order to notify the AdaptiveLightingController of such an event happening OUTSIDE of HomeKit
411
 *    you must call {@link disableAdaptiveLighting} manually!
412
 *   - Be aware that even when the light is turned off the transition will continue to call the SET handler
413
 *    of the ColorTemperature characteristic.
414
 *   - When using Hue/Saturation:
415
 *    When using Hue/Saturation in combination with the ColorTemperature characteristic you need to update the
416
 *    respective other in a particular way depending on if being in "color mode" or "color temperature mode".
417
 *    When a write happens to Hue/Saturation characteristic in is advised to set the internal value of the
418
 *    ColorTemperature to the minimal (NOT RAISING an event).
419
 *    When a write happens to the ColorTemperature characteristic just MUST convert to a proper representation
420
 *    in hue and saturation values, with RAISING an event.
421
 *    As noted above you MUST NOT call the {@link Characteristic.setValue} method for this, as this will be considered
422
 *    a write to the characteristic and will turn off AdaptiveLighting. Instead, you should use
423
 *    {@link Characteristic.updateValue} for this.
424
 *    You can and SHOULD use the supplied utility method {@link ColorUtils.colorTemperatureToHueAndSaturation}
425
 *    for converting mired to hue and saturation values.
426
 *
427
 *
428
 * <b>MANUAL mode:</b>
429
 *
430
 *  Manual mode is recommended for any accessories which support transitions natively on the devices end.
431
 *  Like for example ZigBee lights which support sending transitions directly to the lightbulb which
432
 *  then get executed ON the lightbulb itself reducing unnecessary network traffic.
433
 *  Here is a quick overview what you have to consider to successfully implement AdaptiveLighting support.
434
 *  The AdaptiveLightingController will also in manual mode do all the setup procedure.
435
 *  It will also save the transition schedule to disk to keep AdaptiveLighting enabled across reboots.
436
 *  The "only" thing you have to do yourself is handling the actual transitions, check that event notifications
437
 *  are only sent in the defined interval threshold, adjust the color temperature when brightness is changed
438
 *  and signal that Adaptive Lighting should be disabled if ColorTemperature, Hue or Saturation is changed manually.
439
 *
440
 *  First step is to setup up an event handler for the {@link AdaptiveLightingControllerEvents.UPDATE}, which is called
441
 *  when AdaptiveLighting is enabled, the HomeHub updates the schedule for the next 24 hours or AdaptiveLighting
442
 *  is restored from disk on startup.
443
 *  In the event handler you can get the current schedule via {@link AdaptiveLightingController.getAdaptiveLightingTransitionCurve},
444
 *  retrieve current intervals like {@link AdaptiveLightingController.getAdaptiveLightingUpdateInterval} or
445
 *  {@link AdaptiveLightingController.getAdaptiveLightingNotifyIntervalThreshold} and get the date in epoch millis
446
 *  when the current transition curve started using {@link AdaptiveLightingController.getAdaptiveLightingStartTimeOfTransition}.
447
 *  Additionally {@link AdaptiveLightingController.getAdaptiveLightingBrightnessMultiplierRange} can be used
448
 *  to get the valid range for the brightness value to calculate the brightness adjustment factor.
449
 *  The method {@link AdaptiveLightingController.isAdaptiveLightingActive} can be used to check if AdaptiveLighting is enabled.
450
 *  Besides, actually running the transition (see {@link AdaptiveLightingTransitionCurveEntry}) you must correctly update
451
 *  the color temperature when the brightness of the lightbulb changes (see {@link AdaptiveLightingTransitionCurveEntry.brightnessAdjustmentFactor}),
452
 *  and signal when AdaptiveLighting got disabled by calling {@link AdaptiveLightingController.disableAdaptiveLighting}
453
 *  when ColorTemperature, Hue or Saturation where changed manually.
454
 *  Lastly you should set up a event handler for the {@link AdaptiveLightingControllerEvents.DISABLED} event.
455
 *  In yet unknown circumstances HomeKit may also send a dedicated disable command via the control point characteristic.
456
 *  Be prepared to handle that.
457
 *
458
 *  @group Adaptive Lighting
459
 */
460
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
461
export class AdaptiveLightingController
18✔
462
  extends EventEmitter
463
  implements SerializableController<ControllerServiceMap, SerializedAdaptiveLightingControllerState> {
464

465
  private stateChangeDelegate?: StateChangeDelegate;
466

467
  private readonly lightbulb: Lightbulb;
468
  private readonly mode: AdaptiveLightingControllerMode;
469
  private readonly customTemperatureAdjustment: number;
470

471
  private readonly adjustmentFactorChangedListener: (change: CharacteristicChange) => void;
472
  private readonly characteristicManualWrittenChangeListener: (change: CharacteristicChange) => void;
473

474
  private supportedTransitionConfiguration?: SupportedCharacteristicValueTransitionConfiguration;
475
  private transitionControl?: CharacteristicValueTransitionControl;
476
  private activeTransitionCount?: CharacteristicValueActiveTransitionCount;
477

478
  private colorTemperatureCharacteristic?: ColorTemperature;
479
  private brightnessCharacteristic?: Brightness;
480
  private saturationCharacteristic?: Saturation;
481
  private hueCharacteristic?: Hue;
482

483
  private activeTransition?: ActiveAdaptiveLightingTransition;
484
  private didRunFirstInitializationStep = false;
×
485
  private updateTimeout?: NodeJS.Timeout;
486

487
  private lastTransitionPointInfo?: SavedLastTransitionPointInfo;
488
  private lastEventNotificationSent = 0;
×
489
  private lastNotifiedTemperatureValue = 0;
×
490
  private lastNotifiedSaturationValue = 0;
×
491
  private lastNotifiedHueValue = 0;
×
492

493
  /**
494
   * Creates a new instance of the AdaptiveLightingController.
495
   * Refer to the {@link AdaptiveLightingController} documentation on how to use it.
496
   *
497
   * @param service - The lightbulb to which Adaptive Lighting support should be added.
498
   * @param options - Optional options to define the operating mode (automatic vs manual).
499
   */
500
  constructor(service: Lightbulb, options?: AdaptiveLightingOptions) {
501
    super();
×
502
    this.lightbulb = service;
×
503
    this.mode = options?.controllerMode ?? AdaptiveLightingControllerMode.AUTOMATIC;
×
504
    this.customTemperatureAdjustment = options?.customTemperatureAdjustment ?? 0;
×
505

506
    assert(this.lightbulb.testCharacteristic(Characteristic.ColorTemperature), "Lightbulb must have the ColorTemperature characteristic added!");
×
507
    assert(this.lightbulb.testCharacteristic(Characteristic.Brightness), "Lightbulb must have the Brightness characteristic added!");
×
508

509
    this.adjustmentFactorChangedListener = this.handleAdjustmentFactorChanged.bind(this);
×
510
    this.characteristicManualWrittenChangeListener = this.handleCharacteristicManualWritten.bind(this);
×
511
  }
512

513
  /**
514
   * @private
515
   */
516
  controllerId(): ControllerIdentifier {
517
    return DefaultControllerType.CHARACTERISTIC_TRANSITION + "-" + this.lightbulb.getServiceId();
×
518
  }
519

520
  // ----------- PUBLIC API START -----------
521

522
  /**
523
   * Returns if a Adaptive Lighting transition is currently active.
524
   */
525
  public isAdaptiveLightingActive(): boolean {
526
    return !!this.activeTransition;
×
527
  }
528

529
  /**
530
   * This method can be called to manually disable the current active Adaptive Lighting transition.
531
   * When using {@link AdaptiveLightingControllerMode.AUTOMATIC} you won't need to call this method.
532
   * In {@link AdaptiveLightingControllerMode.MANUAL} you must call this method when Adaptive Lighting should be disabled.
533
   * This is the case when the user manually changes the value of Hue, Saturation or ColorTemperature characteristics
534
   * (or if any of those values is changed by physical interaction with the lightbulb).
535
   */
536
  public disableAdaptiveLighting(): void {
537
    if (this.updateTimeout) {
×
538
      clearTimeout(this.updateTimeout);
×
539
      this.updateTimeout = undefined;
×
540
    }
541

542
    if (this.activeTransition) {
×
543
      this.colorTemperatureCharacteristic?.removeListener(CharacteristicEventTypes.CHANGE, this.characteristicManualWrittenChangeListener);
×
544
      this.brightnessCharacteristic?.removeListener(CharacteristicEventTypes.CHANGE, this.adjustmentFactorChangedListener);
×
545
      this.hueCharacteristic?.removeListener(CharacteristicEventTypes.CHANGE, this.characteristicManualWrittenChangeListener);
×
546
      this.saturationCharacteristic?.removeListener(CharacteristicEventTypes.CHANGE, this.characteristicManualWrittenChangeListener);
×
547

548
      this.activeTransition = undefined;
×
549

550
      this.stateChangeDelegate?.();
×
551
    }
552

553
    this.colorTemperatureCharacteristic = undefined;
×
554
    this.brightnessCharacteristic = undefined;
×
555
    this.hueCharacteristic = undefined;
×
556
    this.saturationCharacteristic = undefined;
×
557

558
    this.lastTransitionPointInfo = undefined;
×
559
    this.lastEventNotificationSent = 0;
×
560
    this.lastNotifiedTemperatureValue = 0;
×
561
    this.lastNotifiedSaturationValue = 0;
×
562
    this.lastNotifiedHueValue = 0;
×
563

564
    this.didRunFirstInitializationStep = false;
×
565

566
    this.activeTransitionCount?.sendEventNotification(0);
×
567

568
    debug("[%s] Disabling adaptive lighting", this.lightbulb.displayName);
×
569
  }
570

571
  /**
572
   * Returns the time where the current transition curve was started in epoch time millis.
573
   * A transition curves is active for 24 hours typically and is renewed every 24 hours by a HomeHub.
574
   * Additionally see {@link getAdaptiveLightingTimeOffset}.
575
   */
576
  public getAdaptiveLightingStartTimeOfTransition(): number {
577
    if (!this.activeTransition) {
×
578
      throw new Error("There is no active transition!");
×
579
    }
580
    return this.activeTransition.transitionStartMillis;
×
581
  }
582

583
  /**
584
   * It is not necessarily given, that we have the same time (or rather the correct time) as the HomeKit controller
585
   * who set up the transition schedule.
586
   * Thus we record the delta between our current time and the the time send with the setup request.
587
   * <code>timeOffset</code> is defined as <code>Date.now() - getAdaptiveLightingStartTimeOfTransition();</code>.
588
   * So in the case were we actually have a correct local time, it most likely will be positive (due to network latency).
589
   * But of course it can also be negative.
590
   */
591
  public getAdaptiveLightingTimeOffset(): number {
592
    if (!this.activeTransition) {
×
593
      throw new Error("There is no active transition!");
×
594
    }
595
    return this.activeTransition.timeMillisOffset;
×
596
  }
597

598
  public getAdaptiveLightingTransitionCurve(): AdaptiveLightingTransitionCurveEntry[] {
599
    if (!this.activeTransition) {
×
600
      throw new Error("There is no active transition!");
×
601
    }
602
    return this.activeTransition.transitionCurve;
×
603
  }
604

605
  public getAdaptiveLightingBrightnessMultiplierRange(): BrightnessAdjustmentMultiplierRange {
606
    if (!this.activeTransition) {
×
607
      throw new Error("There is no active transition!");
×
608
    }
609
    return this.activeTransition.brightnessAdjustmentRange;
×
610
  }
611

612
  /**
613
   * This method returns the interval (in milliseconds) in which the light should update its internal color temperature
614
   * (aka changes it physical color).
615
   * A lightbulb should ideally change this also when turned of in oder to have a smooth transition when turning the light on.
616
   *
617
   * Typically this evaluates to 60000 milliseconds (60 seconds).
618
   */
619
  public getAdaptiveLightingUpdateInterval(): number {
620
    if (!this.activeTransition) {
×
621
      throw new Error("There is no active transition!");
×
622
    }
623
    return this.activeTransition.updateInterval;
×
624
  }
625

626
  /**
627
   * Returns the minimum interval threshold (in milliseconds) a accessory may notify HomeKit controllers about a new
628
   * color temperature value via event notifications (what happens when you call {@link Characteristic.updateValue}).
629
   * Meaning the accessory should only send event notifications to subscribed HomeKit controllers at the specified interval.
630
   *
631
   * Typically this evaluates to 600000 milliseconds (10 minutes).
632
   */
633
  public getAdaptiveLightingNotifyIntervalThreshold(): number {
634
    if (!this.activeTransition) {
×
635
      throw new Error("There is no active transition!");
×
636
    }
637
    return this.activeTransition.notifyIntervalThreshold;
×
638
  }
639

640
  // ----------- PUBLIC API END -----------
641

642
  private handleActiveTransitionUpdated(calledFromDeserializer = false): void {
×
643
    if (this.activeTransitionCount) {
×
644
      if (!calledFromDeserializer) {
×
645
        this.activeTransitionCount.sendEventNotification(1);
×
646
      } else {
647
        this.activeTransitionCount.value = 1;
×
648
      }
649
    }
650

651
    if (this.mode === AdaptiveLightingControllerMode.AUTOMATIC) {
×
652
      this.scheduleNextUpdate();
×
653
    } else if (this.mode === AdaptiveLightingControllerMode.MANUAL) {
×
654
      if (!this.activeTransition) {
×
655
        throw new Error("There is no active transition!");
×
656
      }
657

658
      const update: AdaptiveLightingControllerUpdate = {
×
659
        transitionStartMillis: this.activeTransition.transitionStartMillis,
660
        timeMillisOffset: this.activeTransition.timeMillisOffset,
661
        transitionCurve: this.activeTransition.transitionCurve,
662
        brightnessAdjustmentRange: this.activeTransition.brightnessAdjustmentRange,
663
        updateInterval: this.activeTransition.updateInterval,
664
        notifyIntervalThreshold: this.activeTransition.notifyIntervalThreshold,
665
      };
666

667
      this.emit(AdaptiveLightingControllerEvents.UPDATE, update);
×
668
    } else {
669
      throw new Error("Unsupported adaptive lighting controller mode: " + this.mode);
×
670
    }
671

672
    if (!calledFromDeserializer) {
×
673
      this.stateChangeDelegate?.();
×
674
    }
675
  }
676

677
  private handleAdaptiveLightingEnabled(): void { // this method is run when the initial curve was sent
678
    if (!this.activeTransition) {
×
679
      throw new Error("There is no active transition!");
×
680
    }
681

682
    this.colorTemperatureCharacteristic = this.lightbulb.getCharacteristic(Characteristic.ColorTemperature);
×
683
    this.brightnessCharacteristic = this.lightbulb.getCharacteristic(Characteristic.Brightness);
×
684

685
    this.colorTemperatureCharacteristic.on(CharacteristicEventTypes.CHANGE, this.characteristicManualWrittenChangeListener);
×
686
    this.brightnessCharacteristic.on(CharacteristicEventTypes.CHANGE, this.adjustmentFactorChangedListener);
×
687

688
    if (this.lightbulb.testCharacteristic(Characteristic.Hue)) {
×
689
      this.hueCharacteristic = this.lightbulb.getCharacteristic(Characteristic.Hue)
×
690
        .on(CharacteristicEventTypes.CHANGE, this.characteristicManualWrittenChangeListener);
691
    }
692
    if (this.lightbulb.testCharacteristic(Characteristic.Saturation)) {
×
693
      this.saturationCharacteristic = this.lightbulb.getCharacteristic(Characteristic.Saturation)
×
694
        .on(CharacteristicEventTypes.CHANGE, this.characteristicManualWrittenChangeListener);
695
    }
696
  }
697

698
  private handleAdaptiveLightingDisabled(): void {
699
    if (this.mode === AdaptiveLightingControllerMode.MANUAL && this.activeTransition) { // only emit the event if a transition is actually enabled
×
700
      this.emit(AdaptiveLightingControllerEvents.DISABLED);
×
701
    }
702
    this.disableAdaptiveLighting();
×
703
  }
704

705
  private handleAdjustmentFactorChanged(change: CharacteristicChange): void {
706
    if (change.newValue === change.oldValue) {
×
707
      return;
×
708
    }
709

710
    // consider the following scenario:
711
    // a HomeKit controller queries the light (meaning e.g. Brightness, Hue and Saturation characteristics).
712
    // As of the implementation of the light the brightness characteristic get handler returns first
713
    // (and returns a value different than the cached value).
714
    // This change handler gets called and we will update the color temperature accordingly
715
    // (which also adjusts the internal cached values for Hue and Saturation).
716
    // After some short time the Hue or Saturation get handler return with the last known value to the plugin.
717
    // As those values now differ from the cached values (we already updated) we get a call to handleCharacteristicManualWritten
718
    // which again disables adaptive lighting.
719

720
    if (change.reason === ChangeReason.READ) {
×
721
      // if the reason is a read request, we expect that Hue/Saturation are also read
722
      // thus we postpone our update to ColorTemperature a bit.
723
      // It doesn't ensure that those race conditions do not happen anymore, but with a 1s delay it reduces the possibility by a bit
724
      setTimeout(() => {
×
725
        if (!this.activeTransition) {
×
726
          return; // was disabled in the mean time
×
727
        }
728
        this.scheduleNextUpdate(true);
×
729
      }, 1000).unref();
730
    } else {
731
      this.scheduleNextUpdate(true); // run a dry scheduleNextUpdate to adjust the colorTemperature using the new brightness value
×
732
    }
733
  }
734

735
  /**
736
   * This method is called when a change happens to the Hue/Saturation or ColorTemperature characteristic.
737
   * When such a write happens (caused by the user changing the color/temperature) Adaptive Lighting must be disabled.
738
   *
739
   * @param change
740
   */
741
  private handleCharacteristicManualWritten(change: CharacteristicChange): void {
742
    if (change.reason === ChangeReason.WRITE && !(isAdaptiveLightingContext(change.context) && change.context.controller === this)) {
×
743
      // we ignore write request which are the result of calls made to updateValue or sendEventNotification
744
      // or the result of a changed value returned by a read handler
745
      // or the change was done by the controller itself
746

747
      debug("[%s] Received a manual write to an characteristic (newValue: %d, oldValue: %d, reason: %s). Thus disabling adaptive lighting!",
×
748
        this.lightbulb.displayName, change.newValue, change.oldValue, change.reason);
749
      this.disableAdaptiveLighting();
×
750
    }
751
  }
752

753
  /**
754
   * Retrieve the {@link AdaptiveLightingTransitionPoint} for the current timestamp.
755
   * Returns undefined if the current transition schedule reached its end.
756
   */
757
  public getCurrentAdaptiveLightingTransitionPoint(): AdaptiveLightingTransitionPoint | undefined {
758
    if (!this.activeTransition) {
×
759
      throw new Error("Cannot calculate current transition point if no transition is active!");
×
760
    }
761

762
    // adjustedNow is the now() date corrected to the time of the initiating controller
763
    const adjustedNow = Date.now() - this.activeTransition.timeMillisOffset;
×
764
    // "offset" since the start of the transition schedule
765
    const offset = adjustedNow - this.activeTransition.transitionStartMillis;
×
766

767
    let i = this.lastTransitionPointInfo?.curveIndex ?? 0;
×
768
    let lowerBoundTimeOffset = this.lastTransitionPointInfo?.lowerBoundTimeOffset ?? 0; // time offset to the lowerBound transition entry
×
769
    let lowerBound: AdaptiveLightingTransitionCurveEntry | undefined = undefined;
×
770
    let upperBound: AdaptiveLightingTransitionCurveEntry | undefined = undefined;
×
771

772
    for (; i + 1 < this.activeTransition.transitionCurve.length; i++) {
×
773
      const lowerBound0 = this.activeTransition.transitionCurve[i];
×
774
      const upperBound0 = this.activeTransition.transitionCurve[i + 1];
×
775

776
      const lowerBoundDuration = lowerBound0.duration ?? 0;
×
777
      lowerBoundTimeOffset += lowerBound0.transitionTime;
×
778

779
      if (offset >= lowerBoundTimeOffset) {
×
780
        if (offset <= lowerBoundTimeOffset + lowerBoundDuration + upperBound0.transitionTime) {
×
781
          lowerBound = lowerBound0;
×
782
          upperBound = upperBound0;
×
783
          break;
×
784
        }
785
      } else if (this.lastTransitionPointInfo) {
×
786
        // if we reached here the entry in the transitionCurve we are searching for is somewhere before current i.
787
        // This can only happen when we have a faulty lastTransitionPointInfo (otherwise we would start from i=0).
788
        // Thus we try again by searching from i=0
789
        this.lastTransitionPointInfo = undefined;
×
790
        return this.getCurrentAdaptiveLightingTransitionPoint();
×
791
      }
792

793
      lowerBoundTimeOffset += lowerBoundDuration;
×
794
    }
795

796
    if (!lowerBound || !upperBound) {
×
797
      this.lastTransitionPointInfo = undefined;
×
798
      return undefined;
×
799
    }
800

801
    this.lastTransitionPointInfo = {
×
802
      curveIndex: i,
803
      // we need to subtract lowerBound.transitionTime. When we start the loop above
804
      // with a saved transition point, we will always add lowerBound.transitionTime as first step.
805
      // Otherwise our calculations are simply wrong.
806
      lowerBoundTimeOffset: lowerBoundTimeOffset - lowerBound.transitionTime,
807
    };
808

809
    return {
×
810
      lowerBoundTimeOffset: lowerBoundTimeOffset,
811
      transitionOffset: offset - lowerBoundTimeOffset,
812
      lowerBound: lowerBound,
813
      upperBound: upperBound,
814
    };
815
  }
816

817
  private scheduleNextUpdate(dryRun = false): void {
×
818
    if (!this.activeTransition) {
×
819
      throw new Error("tried scheduling transition when no transition was active!");
×
820
    }
821

822
    if (!dryRun) {
×
823
      this.updateTimeout = undefined;
×
824
    }
825

826
    if (!this.didRunFirstInitializationStep) {
×
827
      this.didRunFirstInitializationStep = true;
×
828
      this.handleAdaptiveLightingEnabled();
×
829
    }
830

831
    const transitionPoint = this.getCurrentAdaptiveLightingTransitionPoint();
×
832
    if (!transitionPoint) {
×
833
      debug("[%s] Reached end of transition curve!", this.lightbulb.displayName);
×
834
      if (!dryRun) {
×
835
        // the transition schedule is only for 24 hours, we reached the end?
836
        this.disableAdaptiveLighting();
×
837
      }
838
      return;
×
839
    }
840

841
    const lowerBound = transitionPoint.lowerBound;
×
842
    const upperBound = transitionPoint.upperBound;
×
843

844
    let interpolatedTemperature: number;
845
    let interpolatedAdjustmentFactor: number;
846
    if (lowerBound.duration && transitionPoint.transitionOffset  <= lowerBound.duration) {
×
847
      interpolatedTemperature = lowerBound.temperature;
×
848
      interpolatedAdjustmentFactor = lowerBound.brightnessAdjustmentFactor;
×
849
    } else {
850
      const timePercentage = (transitionPoint.transitionOffset - (lowerBound.duration ?? 0)) / upperBound.transitionTime;
×
851
      interpolatedTemperature = lowerBound.temperature + (upperBound.temperature - lowerBound.temperature) * timePercentage;
×
852
      interpolatedAdjustmentFactor = lowerBound.brightnessAdjustmentFactor
×
853
        + (upperBound.brightnessAdjustmentFactor - lowerBound.brightnessAdjustmentFactor) * timePercentage;
854
    }
855

856
    const adjustmentMultiplier = Math.max(
×
857
      this.activeTransition.brightnessAdjustmentRange.minBrightnessValue,
858
      Math.min(
859
        this.activeTransition.brightnessAdjustmentRange.maxBrightnessValue,
860
        this.brightnessCharacteristic?.value as number, // get handler is not called for optimal performance
861
      ),
862
    );
863

864
    let temperature = Math.round(interpolatedTemperature + interpolatedAdjustmentFactor * adjustmentMultiplier);
×
865

866
    // apply any manually applied temperature adjustments
867
    temperature += this.customTemperatureAdjustment;
×
868

869
    const min = this.colorTemperatureCharacteristic?.props.minValue ?? 140;
×
870
    const max = this.colorTemperatureCharacteristic?.props.maxValue ?? 500;
×
871
    temperature = Math.max(min, Math.min(max, temperature));
×
872
    const color = ColorUtils.colorTemperatureToHueAndSaturation(temperature);
×
873

874
    debug("[%s] Next temperature value is %d (for brightness %d adj: %s)",
×
875
      this.lightbulb.displayName, temperature, adjustmentMultiplier, this.customTemperatureAdjustment);
876

877
    const context: AdaptiveLightingCharacteristicContext = {
×
878
      controller: this,
879
      omitEventUpdate: true,
880
    };
881

882
    /*
883
     * We set saturation and hue values BEFORE we call the ColorTemperature SET handler (via setValue).
884
     * First thought was so the API user could get the values in the SET handler of the color temperature characteristic.
885
     * Do this is probably not really elegant cause this would only work when Adaptive Lighting is turned on
886
     * an the accessory MUST in any case update the Hue/Saturation values on a ColorTemperature write
887
     * (obviously only if Hue/Saturation characteristics are added to the service).
888
     *
889
     * The clever thing about this though is that, that it prevents notifications from being sent for Hue and Saturation
890
     * outside the specified notifyIntervalThreshold (see below where notifications are manually sent).
891
     * As the dev will or must call something like updateValue to propagate the updated hue and saturation values
892
     * to all HomeKit clients (so that the color is reflected in the UI), HAP-NodeJS won't send notifications
893
     * as the values are the same.
894
     * This of course only works if the plugin uses the exact same algorithm of "converting" the color temperature
895
     * value to the hue and saturation representation.
896
     */
897
    if (this.saturationCharacteristic) {
×
898
      this.saturationCharacteristic.value = color.saturation;
×
899
    }
900
    if (this.hueCharacteristic) {
×
901
      this.hueCharacteristic.value = color.hue;
×
902
    }
903

904
    this.colorTemperatureCharacteristic?.handleSetRequest(temperature, undefined, context).catch(reason => { // reason is HAPStatus code
×
905
      debug("[%s] Failed to next adaptive lighting transition point: %d", this.lightbulb.displayName, reason);
×
906
    });
907

908
    if (!this.activeTransition) {
×
909
      console.warn("[" + this.lightbulb.displayName + "] Adaptive Lighting was probably disable my mistake by some call in " +
×
910
        "the SET handler of the ColorTemperature characteristic! " +
911
        "Please check that you don't call setValue/setCharacteristic on the Hue, Saturation or ColorTemperature characteristic!");
912
      return;
×
913
    }
914

915
    const now = Date.now();
×
916
    if (!dryRun && now - this.lastEventNotificationSent >= this.activeTransition.notifyIntervalThreshold) {
×
917
      debug("[%s] Sending event notifications for current transition!", this.lightbulb.displayName);
×
918
      this.lastEventNotificationSent = now;
×
919

920
      const eventContext: AdaptiveLightingCharacteristicContext = {
×
921
        controller: this,
922
      };
923

924
      if (this.lastNotifiedTemperatureValue !== temperature) {
×
925
        this.colorTemperatureCharacteristic?.sendEventNotification(temperature, eventContext);
×
926
        this.lastNotifiedTemperatureValue = temperature;
×
927
      }
928
      if (this.saturationCharacteristic && this.lastNotifiedSaturationValue !== color.saturation) {
×
929
        this.saturationCharacteristic.sendEventNotification(color.saturation, eventContext);
×
930
        this.lastNotifiedSaturationValue = color.saturation;
×
931
      }
932
      if (this.hueCharacteristic && this.lastNotifiedHueValue !== color.hue) {
×
933
        this.hueCharacteristic.sendEventNotification(color.hue, eventContext);
×
934
        this.lastNotifiedHueValue = color.hue;
×
935
      }
936
    }
937

938
    if (!dryRun) {
×
939
      this.updateTimeout = setTimeout(this.scheduleNextUpdate.bind(this), this.activeTransition.updateInterval);
×
940
    }
941
  }
942

943
  /**
944
   * @private
945
   */
946
  constructServices(): ControllerServiceMap {
947
    return {};
×
948
  }
949

950
  /**
951
   * @private
952
   */
953
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
954
  initWithServices(serviceMap: ControllerServiceMap): void | ControllerServiceMap {
955
    // do nothing
956
  }
957

958
  /**
959
   * @private
960
   */
961
  configureServices(): void {
962
    this.supportedTransitionConfiguration = this.lightbulb.getCharacteristic(Characteristic.SupportedCharacteristicValueTransitionConfiguration);
×
963
    this.transitionControl = this.lightbulb.getCharacteristic(Characteristic.CharacteristicValueTransitionControl)
×
964
      .updateValue("");
965
    this.activeTransitionCount = this.lightbulb.getCharacteristic(Characteristic.CharacteristicValueActiveTransitionCount)
×
966
      .updateValue(0);
967

968
    this.supportedTransitionConfiguration
×
969
      .onGet(this.handleSupportedTransitionConfigurationRead.bind(this));
970
    this.transitionControl
×
971
      .onGet(() => {
972
        return this.buildTransitionControlResponseBuffer().toString("base64");
×
973
      })
974
      .onSet(value => {
975
        try {
×
976
          return this.handleTransitionControlWrite(value);
×
977
        } catch (error) {
978
          console.warn(`[%s] DEBUG: '${value}'`);
×
979
          console.warn("[%s] Encountered error on CharacteristicValueTransitionControl characteristic: " + error.stack);
×
980
          this.disableAdaptiveLighting();
×
981
          throw new HapStatusError(HAPStatus.SERVICE_COMMUNICATION_FAILURE);
×
982
        }
983
      });
984
  }
985

986
  /**
987
   * @private
988
   */
989
  handleControllerRemoved(): void {
990
    this.lightbulb.removeCharacteristic(this.supportedTransitionConfiguration!);
×
991
    this.lightbulb.removeCharacteristic(this.transitionControl!);
×
992
    this.lightbulb.removeCharacteristic(this.activeTransitionCount!);
×
993

994
    this.supportedTransitionConfiguration = undefined;
×
995
    this.transitionControl = undefined;
×
996
    this.activeTransitionCount = undefined;
×
997

998
    this.removeAllListeners();
×
999
  }
1000

1001
  /**
1002
   * @private
1003
   */
1004
  handleFactoryReset(): void {
1005
    this.handleAdaptiveLightingDisabled();
×
1006
  }
1007

1008
  /**
1009
   * @private
1010
   */
1011
  serialize(): SerializedAdaptiveLightingControllerState | undefined {
1012
    if (!this.activeTransition) {
×
1013
      return undefined;
×
1014
    }
1015

1016
    return {
×
1017
      activeTransition: this.activeTransition,
1018
    };
1019
  }
1020

1021
  /**
1022
   * @private
1023
   */
1024
  deserialize(serialized: SerializedAdaptiveLightingControllerState): void {
1025
    this.activeTransition = serialized.activeTransition;
×
1026

1027
    // Data migrations from beta builds
1028
    if (!this.activeTransition.transitionId) {
×
1029
      // @ts-expect-error: data migration from beta builds
1030
      this.activeTransition.transitionId = this.activeTransition.id1;
×
1031
      // @ts-expect-error: data migration from beta builds
1032
      delete this.activeTransition.id1;
×
1033
    }
1034

1035
    if (!this.activeTransition.timeMillisOffset) { // compatibility to data produced by early betas
×
1036
      this.activeTransition.timeMillisOffset = 0;
×
1037
    }
1038

1039
    this.handleActiveTransitionUpdated(true);
×
1040
  }
1041

1042
  /**
1043
   * @private
1044
   */
1045
  setupStateChangeDelegate(delegate?: StateChangeDelegate): void {
1046
    this.stateChangeDelegate = delegate;
×
1047
  }
1048

1049
  private handleSupportedTransitionConfigurationRead(): string {
1050
    const brightnessIID = this.lightbulb?.getCharacteristic(Characteristic.Brightness).iid;
×
1051
    const temperatureIID = this.lightbulb?.getCharacteristic(Characteristic.ColorTemperature).iid;
×
1052
    assert(brightnessIID, "iid for brightness characteristic is undefined");
×
1053
    assert(temperatureIID, "iid for temperature characteristic is undefined");
×
1054

1055
    return tlv.encode(SupportedCharacteristicValueTransitionConfigurationsTypes.SUPPORTED_TRANSITION_CONFIGURATION, [
×
1056
      tlv.encode(
1057
        SupportedValueTransitionConfigurationTypes.CHARACTERISTIC_IID, tlv.writeVariableUIntLE(brightnessIID!),
1058
        SupportedValueTransitionConfigurationTypes.TRANSITION_TYPE, TransitionType.BRIGHTNESS,
1059
      ),
1060
      tlv.encode(
1061
        SupportedValueTransitionConfigurationTypes.CHARACTERISTIC_IID, tlv.writeVariableUIntLE(temperatureIID!),
1062
        SupportedValueTransitionConfigurationTypes.TRANSITION_TYPE, TransitionType.COLOR_TEMPERATURE,
1063
      ),
1064
    ]).toString("base64");
1065
  }
1066

1067
  private buildTransitionControlResponseBuffer(time?: number): Buffer {
1068
    if (!this.activeTransition) {
×
1069
      return Buffer.alloc(0);
×
1070
    }
1071

1072
    const active = this.activeTransition;
×
1073

1074
    const timeSinceStart = time ?? (Date.now() - active.timeMillisOffset - active.transitionStartMillis);
×
1075
    const timeSinceStartBuffer = tlv.writeVariableUIntLE(timeSinceStart);
×
1076

1077
    let parameters = tlv.encode(
×
1078
      ValueTransitionParametersTypes.TRANSITION_ID, uuid.write(active.transitionId),
1079
      ValueTransitionParametersTypes.START_TIME, Buffer.from(active.transitionStartBuffer, "hex"),
1080
    );
1081
    if (active.id3) {
×
1082
      parameters = Buffer.concat([
×
1083
        parameters,
1084
        tlv.encode(ValueTransitionParametersTypes.UNKNOWN_3, Buffer.from(active.id3, "hex")),
1085
      ]);
1086
    }
1087

1088
    const status = tlv.encode(
×
1089
      ValueTransitionConfigurationStatusTypes.CHARACTERISTIC_IID, tlv.writeVariableUIntLE(active.iid!),
1090
      ValueTransitionConfigurationStatusTypes.TRANSITION_PARAMETERS, parameters,
1091
      ValueTransitionConfigurationStatusTypes.TIME_SINCE_START, timeSinceStartBuffer,
1092
    );
1093

1094
    return tlv.encode(
×
1095
      ValueTransitionConfigurationResponseTypes.VALUE_CONFIGURATION_STATUS, status,
1096
    );
1097
  }
1098

1099
  private handleTransitionControlWrite(value: CharacteristicValue): string {
1100
    if (typeof value !== "string") {
×
1101
      throw new HapStatusError(HAPStatus.INVALID_VALUE_IN_REQUEST);
×
1102
    }
1103

1104
    const tlvData = tlv.decode(Buffer.from(value, "base64"));
×
1105
    const responseBuffers: Buffer[] = [];
×
1106

1107
    const readTransition = tlvData[TransitionControlTypes.READ_CURRENT_VALUE_TRANSITION_CONFIGURATION];
×
1108
    if (readTransition) {
×
1109
      const readTransitionResponse = this.handleTransitionControlReadTransition(readTransition);
×
1110
      if (readTransitionResponse) {
×
1111
        responseBuffers.push(readTransitionResponse);
×
1112
      }
1113
    }
1114
    const updateTransition = tlvData[TransitionControlTypes.UPDATE_VALUE_TRANSITION_CONFIGURATION];
×
1115
    if (updateTransition) {
×
1116
      const updateTransitionResponse = this.handleTransitionControlUpdateTransition(updateTransition);
×
1117
      if (updateTransitionResponse) {
×
1118
        responseBuffers.push(updateTransitionResponse);
×
1119
      }
1120
    }
1121

1122
    return Buffer.concat(responseBuffers).toString("base64");
×
1123
  }
1124

1125
  private handleTransitionControlReadTransition(buffer: Buffer): Buffer | undefined {
1126
    const readTransition = tlv.decode(buffer);
×
1127

1128
    const iid = tlv.readVariableUIntLE(readTransition[ReadValueTransitionConfiguration.CHARACTERISTIC_IID]);
×
1129

1130
    if (this.activeTransition) {
×
1131
      if (this.activeTransition.iid !== iid) {
×
1132
        console.warn("[" + this.lightbulb.displayName + "] iid of current adaptive lighting transition (" + this.activeTransition.iid
×
1133
          + ") doesn't match the requested one " + iid);
1134
        throw new HapStatusError(HAPStatus.INVALID_VALUE_IN_REQUEST);
×
1135
      }
1136

1137
      let parameters = tlv.encode(
×
1138
        ValueTransitionParametersTypes.TRANSITION_ID, uuid.write(this.activeTransition.transitionId),
1139
        ValueTransitionParametersTypes.START_TIME, Buffer.from(this.activeTransition.transitionStartBuffer, "hex"),
1140
      );
1141
      if (this.activeTransition.id3) {
×
1142
        parameters = Buffer.concat([
×
1143
          parameters,
1144
          tlv.encode(ValueTransitionParametersTypes.UNKNOWN_3, Buffer.from(this.activeTransition.id3, "hex")),
1145
        ]);
1146
      }
1147

1148
      return tlv.encode(
×
1149
        TransitionControlTypes.READ_CURRENT_VALUE_TRANSITION_CONFIGURATION, tlv.encode(
1150
          ValueTransitionConfigurationTypes.CHARACTERISTIC_IID, tlv.writeVariableUIntLE(this.activeTransition.iid),
1151
          ValueTransitionConfigurationTypes.TRANSITION_PARAMETERS, parameters,
1152
          ValueTransitionConfigurationTypes.UNKNOWN_3, 1,
1153
          ValueTransitionConfigurationTypes.TRANSITION_CURVE_CONFIGURATION, tlv.encode(
1154
            TransitionCurveConfigurationTypes.TRANSITION_ENTRY, this.activeTransition.transitionCurve.map((entry, index, array) => {
1155
              const duration = array[index - 1]?.duration ?? 0; // we store stuff differently :sweat_smile:
×
1156

1157
              return tlv.encode(
×
1158
                TransitionEntryTypes.ADJUSTMENT_FACTOR, tlv.writeFloat32LE(entry.brightnessAdjustmentFactor),
1159
                TransitionEntryTypes.VALUE, tlv.writeFloat32LE(entry.temperature),
1160
                TransitionEntryTypes.TRANSITION_OFFSET, tlv.writeVariableUIntLE(entry.transitionTime),
1161
                TransitionEntryTypes.DURATION, tlv.writeVariableUIntLE(duration),
1162
              );
1163
            }),
1164
            TransitionCurveConfigurationTypes.ADJUSTMENT_CHARACTERISTIC_IID, tlv.writeVariableUIntLE(this.activeTransition.brightnessCharacteristicIID),
1165
            TransitionCurveConfigurationTypes.ADJUSTMENT_MULTIPLIER_RANGE, tlv.encode(
1166
              // eslint-disable-next-line max-len
1167
              TransitionAdjustmentMultiplierRange.MINIMUM_ADJUSTMENT_MULTIPLIER, tlv.writeUInt32(this.activeTransition.brightnessAdjustmentRange.minBrightnessValue),
1168
              // eslint-disable-next-line max-len
1169
              TransitionAdjustmentMultiplierRange.MAXIMUM_ADJUSTMENT_MULTIPLIER, tlv.writeUInt32(this.activeTransition.brightnessAdjustmentRange.maxBrightnessValue),
1170
            ),
1171
          ),
1172
          ValueTransitionConfigurationTypes.UPDATE_INTERVAL, tlv.writeVariableUIntLE(this.activeTransition.updateInterval),
1173
          ValueTransitionConfigurationTypes.NOTIFY_INTERVAL_THRESHOLD, tlv.writeVariableUIntLE(this.activeTransition.notifyIntervalThreshold),
1174
        ),
1175
      );
1176
    } else {
1177
      return undefined; // returns empty string
×
1178
    }
1179
  }
1180

1181
  private handleTransitionControlUpdateTransition(buffer: Buffer): Buffer {
1182
    const updateTransition = tlv.decode(buffer);
×
1183
    const transitionConfiguration = tlv.decode(updateTransition[UpdateValueTransitionConfigurationsTypes.VALUE_TRANSITION_CONFIGURATION]);
×
1184

1185
    const iid = tlv.readVariableUIntLE(transitionConfiguration[ValueTransitionConfigurationTypes.CHARACTERISTIC_IID]);
×
1186
    if (!this.lightbulb.getCharacteristicByIID(iid)) {
×
1187
      throw new HapStatusError(HAPStatus.INVALID_VALUE_IN_REQUEST);
×
1188
    }
1189

1190
    const param3 = transitionConfiguration[ValueTransitionConfigurationTypes.UNKNOWN_3]?.readUInt8(0); // when present it is always 1
×
1191
    if (!param3) { // if HomeKit just sends the iid, we consider that as "disable adaptive lighting" (assumption)
×
1192
      this.handleAdaptiveLightingDisabled();
×
1193
      return tlv.encode(TransitionControlTypes.UPDATE_VALUE_TRANSITION_CONFIGURATION, Buffer.alloc(0));
×
1194
    }
1195

1196
    const parametersTLV = tlv.decode(transitionConfiguration[ValueTransitionConfigurationTypes.TRANSITION_PARAMETERS]);
×
1197
    const curveConfiguration = tlv.decodeWithLists(transitionConfiguration[ValueTransitionConfigurationTypes.TRANSITION_CURVE_CONFIGURATION]);
×
1198
    const updateInterval = transitionConfiguration[ValueTransitionConfigurationTypes.UPDATE_INTERVAL]?.readUInt16LE(0);
×
1199
    const notifyIntervalThreshold = transitionConfiguration[ValueTransitionConfigurationTypes.NOTIFY_INTERVAL_THRESHOLD].readUInt32LE(0);
×
1200

1201
    const transitionId = parametersTLV[ValueTransitionParametersTypes.TRANSITION_ID];
×
1202
    const startTime = parametersTLV[ValueTransitionParametersTypes.START_TIME];
×
1203
    const id3 = parametersTLV[ValueTransitionParametersTypes.UNKNOWN_3]; // this may be undefined
×
1204

1205
    const startTimeMillis = epochMillisFromMillisSince2001_01_01Buffer(startTime);
×
1206
    const timeMillisOffset = Date.now() - startTimeMillis;
×
1207

1208
    const transitionCurve: AdaptiveLightingTransitionCurveEntry[] = [];
×
1209
    let previous: AdaptiveLightingTransitionCurveEntry | undefined = undefined;
×
1210

1211
    const transitions = curveConfiguration[TransitionCurveConfigurationTypes.TRANSITION_ENTRY] as Buffer[];
×
1212
    for (const entry of transitions) {
×
1213
      const tlvEntry = tlv.decode(entry);
×
1214

1215
      const adjustmentFactor = tlvEntry[TransitionEntryTypes.ADJUSTMENT_FACTOR].readFloatLE(0);
×
1216
      const value = tlvEntry[TransitionEntryTypes.VALUE].readFloatLE(0);
×
1217

1218
      const transitionOffset = tlv.readVariableUIntLE(tlvEntry[TransitionEntryTypes.TRANSITION_OFFSET]);
×
1219

1220
      const duration = tlvEntry[TransitionEntryTypes.DURATION]? tlv.readVariableUIntLE(tlvEntry[TransitionEntryTypes.DURATION]): undefined;
×
1221

1222
      if (previous) {
×
1223
        previous.duration = duration;
×
1224
      }
1225

1226
      previous = {
×
1227
        temperature: value,
1228
        brightnessAdjustmentFactor: adjustmentFactor,
1229
        transitionTime: transitionOffset,
1230
      };
1231
      transitionCurve.push(previous);
×
1232
    }
1233

1234
    const adjustmentIID = tlv.readVariableUIntLE((curveConfiguration[TransitionCurveConfigurationTypes.ADJUSTMENT_CHARACTERISTIC_IID] as Buffer));
×
1235
    const adjustmentMultiplierRange = tlv.decode(curveConfiguration[TransitionCurveConfigurationTypes.ADJUSTMENT_MULTIPLIER_RANGE] as Buffer);
×
1236
    const minAdjustmentMultiplier = adjustmentMultiplierRange[TransitionAdjustmentMultiplierRange.MINIMUM_ADJUSTMENT_MULTIPLIER].readUInt32LE(0);
×
1237
    const maxAdjustmentMultiplier = adjustmentMultiplierRange[TransitionAdjustmentMultiplierRange.MAXIMUM_ADJUSTMENT_MULTIPLIER].readUInt32LE(0);
×
1238

1239
    this.activeTransition = {
×
1240
      iid: iid,
1241

1242
      transitionStartMillis: startTimeMillis,
1243
      timeMillisOffset: timeMillisOffset,
1244

1245
      transitionId: uuid.unparse(transitionId),
1246
      transitionStartBuffer: startTime.toString("hex"),
1247
      id3: id3?.toString("hex"),
1248

1249
      brightnessCharacteristicIID: adjustmentIID,
1250
      brightnessAdjustmentRange: {
1251
        minBrightnessValue: minAdjustmentMultiplier,
1252
        maxBrightnessValue: maxAdjustmentMultiplier,
1253
      },
1254

1255
      transitionCurve: transitionCurve,
1256

1257
      updateInterval: updateInterval ?? 60000,
×
1258
      notifyIntervalThreshold: notifyIntervalThreshold,
1259
    };
1260

1261
    if (this.updateTimeout) {
×
1262
      clearTimeout(this.updateTimeout);
×
1263
      this.updateTimeout = undefined;
×
1264
      debug("[%s] Adaptive lighting was renewed.", this.lightbulb.displayName);
×
1265
    } else {
1266
      debug("[%s] Adaptive lighting was enabled.", this.lightbulb.displayName);
×
1267
    }
1268

1269
    this.handleActiveTransitionUpdated();
×
1270

1271
    return tlv.encode(
×
1272
      TransitionControlTypes.UPDATE_VALUE_TRANSITION_CONFIGURATION, this.buildTransitionControlResponseBuffer(0),
1273
    );
1274
  }
1275

1276
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc