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

homebridge / HAP-NodeJS / 9647691811

24 Jun 2024 03:01PM UTC coverage: 64.841%. First build
9647691811

Pull #1038

github

web-flow
Merge 68c695488 into e69e41785
Pull Request #1038: AdaptiveLightingController fix & improvement

1870 of 3700 branches covered (50.54%)

Branch coverage included in aggregate %.

0 of 17 new or added lines in 1 file covered. (0.0%)

7414 of 10618 relevant lines covered (69.82%)

310.39 hits per line

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

19.38
/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
   *
338
   * @param event
339
   * @param listener
340
   */
341
  on(event: "update", listener: (update: AdaptiveLightingControllerUpdate) => void): this;
342
  /**
343
   * See {@link AdaptiveLightingControllerEvents.DISABLED}
344
   *
345
   * @param event
346
   * @param listener
347
   */
348
  on(event: "disable", listener: () => void): this;
349

350
  emit(event: "update", update: AdaptiveLightingControllerUpdate): boolean;
351
  emit(event: "disable"): boolean;
352
}
353

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

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

464
  private stateChangeDelegate?: StateChangeDelegate;
465

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

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

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

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

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

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

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

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

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

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

519
  // ----------- PUBLIC API START -----------
520

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

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

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

547
      this.activeTransition = undefined;
×
548

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

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

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

563
    this.didRunFirstInitializationStep = false;
×
564

NEW
565
    this.activeTransitionCount?.sendEventNotification(0);
×
566

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

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

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

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

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

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

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

639
  // ----------- PUBLIC API END -----------
640

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

792
      lowerBoundTimeOffset += lowerBoundDuration;
×
793
    }
794

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

997
    this.removeAllListeners();
×
998
  }
999

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

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

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

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

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

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

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

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

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

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

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

1071
    const active = this.activeTransition;
×
1072

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1241
      transitionStartMillis: startTimeMillis,
1242
      timeMillisOffset: timeMillisOffset,
1243

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

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

1254
      transitionCurve: transitionCurve,
1255

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

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

1268
    this.handleActiveTransitionUpdated();
×
1269

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

1275
}
18✔
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