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

homebridge / HAP-NodeJS / 9850331586

09 Jul 2024 03:09AM UTC coverage: 63.911%. First build
9850331586

Pull #1042

github

web-flow
Linting. (#1057)
Pull Request #1042: latest <- beta

1377 of 2570 branches covered (53.58%)

Branch coverage included in aggregate %.

31 of 74 new or added lines in 10 files covered. (41.89%)

6417 of 9625 relevant lines covered (66.67%)

308.34 hits per line

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

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

351
  /**
352
   * See {@link AdaptiveLightingControllerUpdate}
353
   */
354
  emit(event: "update", update: AdaptiveLightingControllerUpdate): boolean;
355
  emit(event: "disable"): boolean;
356
}
357

358
/**
359
 * @group Adaptive Lighting
360
 */
361
export interface SerializedAdaptiveLightingControllerState {
362
  activeTransition: ActiveAdaptiveLightingTransition;
363
}
364

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

468
  private stateChangeDelegate?: StateChangeDelegate;
469

470
  private readonly lightbulb: Lightbulb;
471
  private readonly mode: AdaptiveLightingControllerMode;
472
  private readonly customTemperatureAdjustment: number;
473

474
  private readonly adjustmentFactorChangedListener: (change: CharacteristicChange) => void;
475
  private readonly characteristicManualWrittenChangeListener: (change: CharacteristicChange) => void;
476

477
  private supportedTransitionConfiguration?: SupportedCharacteristicValueTransitionConfiguration;
478
  private transitionControl?: CharacteristicValueTransitionControl;
479
  private activeTransitionCount?: CharacteristicValueActiveTransitionCount;
480

481
  private colorTemperatureCharacteristic?: ColorTemperature;
482
  private brightnessCharacteristic?: Brightness;
483
  private saturationCharacteristic?: Saturation;
484
  private hueCharacteristic?: Hue;
485

486
  private activeTransition?: ActiveAdaptiveLightingTransition;
487
  private didRunFirstInitializationStep = false;
×
488
  private updateTimeout?: NodeJS.Timeout;
489

490
  private lastTransitionPointInfo?: SavedLastTransitionPointInfo;
491
  private lastEventNotificationSent = 0;
×
492
  private lastNotifiedTemperatureValue = 0;
×
493
  private lastNotifiedSaturationValue = 0;
×
494
  private lastNotifiedHueValue = 0;
×
495

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

509
    assert(this.lightbulb.testCharacteristic(Characteristic.ColorTemperature), "Lightbulb must have the ColorTemperature characteristic added!");
×
510
    assert(this.lightbulb.testCharacteristic(Characteristic.Brightness), "Lightbulb must have the Brightness characteristic added!");
×
511

512
    this.adjustmentFactorChangedListener = this.handleAdjustmentFactorChanged.bind(this);
×
513
    this.characteristicManualWrittenChangeListener = this.handleCharacteristicManualWritten.bind(this);
×
514
  }
515

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

523
  // ----------- PUBLIC API START -----------
524

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

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

545
    if (this.activeTransition) {
×
NEW
546
      this.colorTemperatureCharacteristic?.removeListener(CharacteristicEventTypes.CHANGE, this.characteristicManualWrittenChangeListener);
×
NEW
547
      this.brightnessCharacteristic?.removeListener(CharacteristicEventTypes.CHANGE, this.adjustmentFactorChangedListener);
×
NEW
548
      this.hueCharacteristic?.removeListener(CharacteristicEventTypes.CHANGE, this.characteristicManualWrittenChangeListener);
×
NEW
549
      this.saturationCharacteristic?.removeListener(CharacteristicEventTypes.CHANGE, this.characteristicManualWrittenChangeListener);
×
550

551
      this.activeTransition = undefined;
×
552

553
      this.stateChangeDelegate?.();
×
554
    }
555

556
    this.colorTemperatureCharacteristic = undefined;
×
557
    this.brightnessCharacteristic = undefined;
×
558
    this.hueCharacteristic = undefined;
×
559
    this.saturationCharacteristic = undefined;
×
560

561
    this.lastTransitionPointInfo = undefined;
×
562
    this.lastEventNotificationSent = 0;
×
563
    this.lastNotifiedTemperatureValue = 0;
×
564
    this.lastNotifiedSaturationValue = 0;
×
565
    this.lastNotifiedHueValue = 0;
×
566

567
    this.didRunFirstInitializationStep = false;
×
568

NEW
569
    this.activeTransitionCount?.sendEventNotification(0);
×
570

571
    debug("[%s] Disabling adaptive lighting", this.lightbulb.displayName);
×
572
  }
573

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

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

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

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

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

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

643
  // ----------- PUBLIC API END -----------
644

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

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

NEW
661
      const update: AdaptiveLightingControllerUpdate = {
×
662
        transitionStartMillis: this.activeTransition.transitionStartMillis,
663
        timeMillisOffset: this.activeTransition.timeMillisOffset,
664
        transitionCurve: this.activeTransition.transitionCurve,
665
        brightnessAdjustmentRange: this.activeTransition.brightnessAdjustmentRange,
666
        updateInterval: this.activeTransition.updateInterval,
667
        notifyIntervalThreshold: this.activeTransition.notifyIntervalThreshold,
668
      };
669

NEW
670
      this.emit(AdaptiveLightingControllerEvents.UPDATE, update);
×
671
    } else {
672
      throw new Error("Unsupported adaptive lighting controller mode: " + this.mode);
×
673
    }
674

675
    if (!calledFromDeserializer) {
×
676
      this.stateChangeDelegate?.();
×
677
    }
678
  }
679

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

685
    this.colorTemperatureCharacteristic = this.lightbulb.getCharacteristic(Characteristic.ColorTemperature);
×
686
    this.brightnessCharacteristic = this.lightbulb.getCharacteristic(Characteristic.Brightness);
×
687

688
    this.colorTemperatureCharacteristic.on(CharacteristicEventTypes.CHANGE, this.characteristicManualWrittenChangeListener);
×
689
    this.brightnessCharacteristic.on(CharacteristicEventTypes.CHANGE, this.adjustmentFactorChangedListener);
×
690

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

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

708
  private handleAdjustmentFactorChanged(change: CharacteristicChange): void {
709
    if (change.newValue === change.oldValue) {
×
710
      return;
×
711
    }
712

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

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

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

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

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

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

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

775
    for (; i + 1 < this.activeTransition.transitionCurve.length; i++) {
×
776
      const lowerBound0 = this.activeTransition.transitionCurve[i];
×
777
      const upperBound0 = this.activeTransition.transitionCurve[i + 1];
×
778

779
      const lowerBoundDuration = lowerBound0.duration ?? 0;
×
780
      lowerBoundTimeOffset += lowerBound0.transitionTime;
×
781

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

796
      lowerBoundTimeOffset += lowerBoundDuration;
×
797
    }
798

799
    if (!lowerBound || !upperBound) {
×
800
      this.lastTransitionPointInfo = undefined;
×
801
      return undefined;
×
802
    }
803

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

812
    return {
×
813
      lowerBoundTimeOffset: lowerBoundTimeOffset,
814
      transitionOffset: offset - lowerBoundTimeOffset,
815
      lowerBound: lowerBound,
816
      upperBound: upperBound,
817
    };
818
  }
819

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

825
    if (!dryRun) {
×
826
      this.updateTimeout = undefined;
×
827
    }
828

829
    if (!this.didRunFirstInitializationStep) {
×
830
      this.didRunFirstInitializationStep = true;
×
831
      this.handleAdaptiveLightingEnabled();
×
832
    }
833

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

844
    const lowerBound = transitionPoint.lowerBound;
×
845
    const upperBound = transitionPoint.upperBound;
×
846

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

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

867
    let temperature = Math.round(interpolatedTemperature + interpolatedAdjustmentFactor * adjustmentMultiplier);
×
868

869
    // apply any manually applied temperature adjustments
870
    temperature += this.customTemperatureAdjustment;
×
871

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

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

880
    const context: AdaptiveLightingCharacteristicContext = {
×
881
      controller: this,
882
      omitEventUpdate: true,
883
    };
884

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

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

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

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

923
      const eventContext: AdaptiveLightingCharacteristicContext = {
×
924
        controller: this,
925
      };
926

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

941
    if (!dryRun) {
×
942
      this.updateTimeout = setTimeout(this.scheduleNextUpdate.bind(this), this.activeTransition.updateInterval);
×
943
    }
944
  }
945

946
  /**
947
   * @private
948
   */
949
  constructServices(): ControllerServiceMap {
950
    return {};
×
951
  }
952

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

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

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

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

997
    this.supportedTransitionConfiguration = undefined;
×
998
    this.transitionControl = undefined;
×
999
    this.activeTransitionCount = undefined;
×
1000

1001
    this.removeAllListeners();
×
1002
  }
1003

1004
  /**
1005
   * @private
1006
   */
1007
  handleFactoryReset(): void {
1008
    this.handleAdaptiveLightingDisabled();
×
1009
  }
1010

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

1019
    return {
×
1020
      activeTransition: this.activeTransition,
1021
    };
1022
  }
1023

1024
  /**
1025
   * @private
1026
   */
1027
  deserialize(serialized: SerializedAdaptiveLightingControllerState): void {
1028
    this.activeTransition = serialized.activeTransition;
×
1029

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

1038
    if (!this.activeTransition.timeMillisOffset) { // compatibility to data produced by early betas
×
1039
      this.activeTransition.timeMillisOffset = 0;
×
1040
    }
1041

1042
    this.handleActiveTransitionUpdated(true);
×
1043
  }
1044

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

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

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

1070
  private buildTransitionControlResponseBuffer(time?: number): Buffer {
1071
    if (!this.activeTransition) {
×
1072
      return Buffer.alloc(0);
×
1073
    }
1074

1075
    const active = this.activeTransition;
×
1076

1077
    const timeSinceStart = time ?? (Date.now() - active.timeMillisOffset - active.transitionStartMillis);
×
1078
    const timeSinceStartBuffer = tlv.writeVariableUIntLE(timeSinceStart);
×
1079

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

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

1097
    return tlv.encode(
×
1098
      ValueTransitionConfigurationResponseTypes.VALUE_CONFIGURATION_STATUS, status,
1099
    );
1100
  }
1101

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

1107
    const tlvData = tlv.decode(Buffer.from(value, "base64"));
×
1108
    const responseBuffers: Buffer[] = [];
×
1109

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

1125
    return Buffer.concat(responseBuffers).toString("base64");
×
1126
  }
1127

1128
  private handleTransitionControlReadTransition(buffer: Buffer): Buffer | undefined {
1129
    const readTransition = tlv.decode(buffer);
×
1130

1131
    const iid = tlv.readVariableUIntLE(readTransition[ReadValueTransitionConfiguration.CHARACTERISTIC_IID]);
×
1132

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

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

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

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

1184
  private handleTransitionControlUpdateTransition(buffer: Buffer): Buffer {
1185
    const updateTransition = tlv.decode(buffer);
×
1186
    const transitionConfiguration = tlv.decode(updateTransition[UpdateValueTransitionConfigurationsTypes.VALUE_TRANSITION_CONFIGURATION]);
×
1187

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

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

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

1204
    const transitionId = parametersTLV[ValueTransitionParametersTypes.TRANSITION_ID];
×
1205
    const startTime = parametersTLV[ValueTransitionParametersTypes.START_TIME];
×
1206
    const id3 = parametersTLV[ValueTransitionParametersTypes.UNKNOWN_3]; // this may be undefined
×
1207

1208
    const startTimeMillis = epochMillisFromMillisSince2001_01_01Buffer(startTime);
×
1209
    const timeMillisOffset = Date.now() - startTimeMillis;
×
1210

1211
    const transitionCurve: AdaptiveLightingTransitionCurveEntry[] = [];
×
1212
    let previous: AdaptiveLightingTransitionCurveEntry | undefined = undefined;
×
1213

1214
    const transitions = curveConfiguration[TransitionCurveConfigurationTypes.TRANSITION_ENTRY] as Buffer[];
×
1215
    for (const entry of transitions) {
×
1216
      const tlvEntry = tlv.decode(entry);
×
1217

1218
      const adjustmentFactor = tlvEntry[TransitionEntryTypes.ADJUSTMENT_FACTOR].readFloatLE(0);
×
1219
      const value = tlvEntry[TransitionEntryTypes.VALUE].readFloatLE(0);
×
1220

1221
      const transitionOffset = tlv.readVariableUIntLE(tlvEntry[TransitionEntryTypes.TRANSITION_OFFSET]);
×
1222

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

1225
      if (previous) {
×
1226
        previous.duration = duration;
×
1227
      }
1228

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

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

1242
    this.activeTransition = {
×
1243
      iid: iid,
1244

1245
      transitionStartMillis: startTimeMillis,
1246
      timeMillisOffset: timeMillisOffset,
1247

1248
      transitionId: uuid.unparse(transitionId),
1249
      transitionStartBuffer: startTime.toString("hex"),
1250
      id3: id3?.toString("hex"),
1251

1252
      brightnessCharacteristicIID: adjustmentIID,
1253
      brightnessAdjustmentRange: {
1254
        minBrightnessValue: minAdjustmentMultiplier,
1255
        maxBrightnessValue: maxAdjustmentMultiplier,
1256
      },
1257

1258
      transitionCurve: transitionCurve,
1259

1260
      updateInterval: updateInterval ?? 60000,
×
1261
      notifyIntervalThreshold: notifyIntervalThreshold,
1262
    };
1263

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

1272
    this.handleActiveTransitionUpdated();
×
1273

1274
    return tlv.encode(
×
1275
      TransitionControlTypes.UPDATE_VALUE_TRANSITION_CONFIGURATION, this.buildTransitionControlResponseBuffer(0),
1276
    );
1277
  }
1278

1279
}
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

© 2025 Coveralls, Inc