hap-nodejs
Version:
HAP-NodeJS is a Node.js implementation of HomeKit Accessory Server.
802 lines • 54.5 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AdaptiveLightingController = exports.AdaptiveLightingControllerEvents = exports.AdaptiveLightingControllerMode = void 0;
const tslib_1 = require("tslib");
const assert_1 = tslib_1.__importDefault(require("assert"));
const color_utils_1 = require("../util/color-utils");
const hapStatusError_1 = require("../util/hapStatusError");
const time_1 = require("../util/time");
const uuid = tslib_1.__importStar(require("../util/uuid"));
const debug_1 = tslib_1.__importDefault(require("debug"));
const events_1 = require("events");
const Characteristic_1 = require("../Characteristic");
const tlv = tslib_1.__importStar(require("../util/tlv"));
const debug = (0, debug_1.default)("HAP-NodeJS:Controller:TransitionControl");
var SupportedCharacteristicValueTransitionConfigurationsTypes;
(function (SupportedCharacteristicValueTransitionConfigurationsTypes) {
SupportedCharacteristicValueTransitionConfigurationsTypes[SupportedCharacteristicValueTransitionConfigurationsTypes["SUPPORTED_TRANSITION_CONFIGURATION"] = 1] = "SUPPORTED_TRANSITION_CONFIGURATION";
})(SupportedCharacteristicValueTransitionConfigurationsTypes || (SupportedCharacteristicValueTransitionConfigurationsTypes = {}));
var SupportedValueTransitionConfigurationTypes;
(function (SupportedValueTransitionConfigurationTypes) {
SupportedValueTransitionConfigurationTypes[SupportedValueTransitionConfigurationTypes["CHARACTERISTIC_IID"] = 1] = "CHARACTERISTIC_IID";
SupportedValueTransitionConfigurationTypes[SupportedValueTransitionConfigurationTypes["TRANSITION_TYPE"] = 2] = "TRANSITION_TYPE";
})(SupportedValueTransitionConfigurationTypes || (SupportedValueTransitionConfigurationTypes = {}));
var TransitionType;
(function (TransitionType) {
TransitionType[TransitionType["BRIGHTNESS"] = 1] = "BRIGHTNESS";
TransitionType[TransitionType["COLOR_TEMPERATURE"] = 2] = "COLOR_TEMPERATURE";
})(TransitionType || (TransitionType = {}));
var TransitionControlTypes;
(function (TransitionControlTypes) {
TransitionControlTypes[TransitionControlTypes["READ_CURRENT_VALUE_TRANSITION_CONFIGURATION"] = 1] = "READ_CURRENT_VALUE_TRANSITION_CONFIGURATION";
TransitionControlTypes[TransitionControlTypes["UPDATE_VALUE_TRANSITION_CONFIGURATION"] = 2] = "UPDATE_VALUE_TRANSITION_CONFIGURATION";
})(TransitionControlTypes || (TransitionControlTypes = {}));
var ReadValueTransitionConfiguration;
(function (ReadValueTransitionConfiguration) {
ReadValueTransitionConfiguration[ReadValueTransitionConfiguration["CHARACTERISTIC_IID"] = 1] = "CHARACTERISTIC_IID";
})(ReadValueTransitionConfiguration || (ReadValueTransitionConfiguration = {}));
var UpdateValueTransitionConfigurationsTypes;
(function (UpdateValueTransitionConfigurationsTypes) {
UpdateValueTransitionConfigurationsTypes[UpdateValueTransitionConfigurationsTypes["VALUE_TRANSITION_CONFIGURATION"] = 1] = "VALUE_TRANSITION_CONFIGURATION";
})(UpdateValueTransitionConfigurationsTypes || (UpdateValueTransitionConfigurationsTypes = {}));
var ValueTransitionConfigurationTypes;
(function (ValueTransitionConfigurationTypes) {
// noinspection JSUnusedGlobalSymbols
ValueTransitionConfigurationTypes[ValueTransitionConfigurationTypes["CHARACTERISTIC_IID"] = 1] = "CHARACTERISTIC_IID";
ValueTransitionConfigurationTypes[ValueTransitionConfigurationTypes["TRANSITION_PARAMETERS"] = 2] = "TRANSITION_PARAMETERS";
ValueTransitionConfigurationTypes[ValueTransitionConfigurationTypes["UNKNOWN_3"] = 3] = "UNKNOWN_3";
ValueTransitionConfigurationTypes[ValueTransitionConfigurationTypes["UNKNOWN_4"] = 4] = "UNKNOWN_4";
ValueTransitionConfigurationTypes[ValueTransitionConfigurationTypes["TRANSITION_CURVE_CONFIGURATION"] = 5] = "TRANSITION_CURVE_CONFIGURATION";
ValueTransitionConfigurationTypes[ValueTransitionConfigurationTypes["UPDATE_INTERVAL"] = 6] = "UPDATE_INTERVAL";
ValueTransitionConfigurationTypes[ValueTransitionConfigurationTypes["UNKNOWN_7"] = 7] = "UNKNOWN_7";
ValueTransitionConfigurationTypes[ValueTransitionConfigurationTypes["NOTIFY_INTERVAL_THRESHOLD"] = 8] = "NOTIFY_INTERVAL_THRESHOLD";
})(ValueTransitionConfigurationTypes || (ValueTransitionConfigurationTypes = {}));
var ValueTransitionParametersTypes;
(function (ValueTransitionParametersTypes) {
ValueTransitionParametersTypes[ValueTransitionParametersTypes["TRANSITION_ID"] = 1] = "TRANSITION_ID";
ValueTransitionParametersTypes[ValueTransitionParametersTypes["START_TIME"] = 2] = "START_TIME";
ValueTransitionParametersTypes[ValueTransitionParametersTypes["UNKNOWN_3"] = 3] = "UNKNOWN_3";
})(ValueTransitionParametersTypes || (ValueTransitionParametersTypes = {}));
var TransitionCurveConfigurationTypes;
(function (TransitionCurveConfigurationTypes) {
TransitionCurveConfigurationTypes[TransitionCurveConfigurationTypes["TRANSITION_ENTRY"] = 1] = "TRANSITION_ENTRY";
TransitionCurveConfigurationTypes[TransitionCurveConfigurationTypes["ADJUSTMENT_CHARACTERISTIC_IID"] = 2] = "ADJUSTMENT_CHARACTERISTIC_IID";
TransitionCurveConfigurationTypes[TransitionCurveConfigurationTypes["ADJUSTMENT_MULTIPLIER_RANGE"] = 3] = "ADJUSTMENT_MULTIPLIER_RANGE";
})(TransitionCurveConfigurationTypes || (TransitionCurveConfigurationTypes = {}));
var TransitionEntryTypes;
(function (TransitionEntryTypes) {
TransitionEntryTypes[TransitionEntryTypes["ADJUSTMENT_FACTOR"] = 1] = "ADJUSTMENT_FACTOR";
TransitionEntryTypes[TransitionEntryTypes["VALUE"] = 2] = "VALUE";
TransitionEntryTypes[TransitionEntryTypes["TRANSITION_OFFSET"] = 3] = "TRANSITION_OFFSET";
TransitionEntryTypes[TransitionEntryTypes["DURATION"] = 4] = "DURATION";
})(TransitionEntryTypes || (TransitionEntryTypes = {}));
var TransitionAdjustmentMultiplierRange;
(function (TransitionAdjustmentMultiplierRange) {
TransitionAdjustmentMultiplierRange[TransitionAdjustmentMultiplierRange["MINIMUM_ADJUSTMENT_MULTIPLIER"] = 1] = "MINIMUM_ADJUSTMENT_MULTIPLIER";
TransitionAdjustmentMultiplierRange[TransitionAdjustmentMultiplierRange["MAXIMUM_ADJUSTMENT_MULTIPLIER"] = 2] = "MAXIMUM_ADJUSTMENT_MULTIPLIER";
})(TransitionAdjustmentMultiplierRange || (TransitionAdjustmentMultiplierRange = {}));
var ValueTransitionConfigurationResponseTypes;
(function (ValueTransitionConfigurationResponseTypes) {
ValueTransitionConfigurationResponseTypes[ValueTransitionConfigurationResponseTypes["VALUE_CONFIGURATION_STATUS"] = 1] = "VALUE_CONFIGURATION_STATUS";
})(ValueTransitionConfigurationResponseTypes || (ValueTransitionConfigurationResponseTypes = {}));
var ValueTransitionConfigurationStatusTypes;
(function (ValueTransitionConfigurationStatusTypes) {
ValueTransitionConfigurationStatusTypes[ValueTransitionConfigurationStatusTypes["CHARACTERISTIC_IID"] = 1] = "CHARACTERISTIC_IID";
ValueTransitionConfigurationStatusTypes[ValueTransitionConfigurationStatusTypes["TRANSITION_PARAMETERS"] = 2] = "TRANSITION_PARAMETERS";
ValueTransitionConfigurationStatusTypes[ValueTransitionConfigurationStatusTypes["TIME_SINCE_START"] = 3] = "TIME_SINCE_START";
})(ValueTransitionConfigurationStatusTypes || (ValueTransitionConfigurationStatusTypes = {}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isAdaptiveLightingContext(context) {
return context && "controller" in context;
}
/**
* Defines in which mode the {@link AdaptiveLightingController} will operate in.
* @group Adaptive Lighting
*/
var AdaptiveLightingControllerMode;
(function (AdaptiveLightingControllerMode) {
/**
* In automatic mode pretty much everything from setup to transition scheduling is done by the controller.
*/
AdaptiveLightingControllerMode[AdaptiveLightingControllerMode["AUTOMATIC"] = 1] = "AUTOMATIC";
/**
* In manual mode setup is done by the controller but the actual transition must be done by the user.
* This is useful for lights which natively support transitions.
*/
AdaptiveLightingControllerMode[AdaptiveLightingControllerMode["MANUAL"] = 2] = "MANUAL";
})(AdaptiveLightingControllerMode || (exports.AdaptiveLightingControllerMode = AdaptiveLightingControllerMode = {}));
/**
* @group Adaptive Lighting
*/
var AdaptiveLightingControllerEvents;
(function (AdaptiveLightingControllerEvents) {
/**
* This event is called once a HomeKit controller enables Adaptive Lighting
* or a HomeHub sends an updated transition schedule for the next 24 hours.
* This is also called on startup when AdaptiveLighting was previously enabled.
*/
AdaptiveLightingControllerEvents["UPDATE"] = "update";
/**
* In yet unknown circumstances HomeKit may also send a dedicated disable command
* via the control point characteristic. You may want to handle that in manual mode as well.
* The current transition will still be associated with the controller object when this event is called.
*/
AdaptiveLightingControllerEvents["DISABLED"] = "disable";
})(AdaptiveLightingControllerEvents || (exports.AdaptiveLightingControllerEvents = AdaptiveLightingControllerEvents = {}));
/**
* This class allows adding Adaptive Lighting support to Lightbulb services.
* The Lightbulb service MUST have the {@link Characteristic.ColorTemperature} characteristic AND
* the {@link Characteristic.Brightness} characteristic added.
* The light may also expose {@link Characteristic.Hue} and {@link Characteristic.Saturation} characteristics
* (though additional work is required to keep them in sync with the color temperature characteristic. see below)
*
* How Adaptive Lighting works:
* When enabling AdaptiveLighting the iDevice will send a transition schedule for the next 24 hours.
* This schedule will be renewed all 24 hours by a HomeHub in your home
* (updating the schedule according to your current day/night situation).
* Once enabled the lightbulb will execute the provided transitions. The color temperature value set is always
* dependent on the current brightness value. Meaning brighter light will be colder and darker light will be warmer.
* HomeKit considers Adaptive Lighting to be disabled as soon a write happens to either the
* Hue/Saturation or the ColorTemperature characteristics.
* The AdaptiveLighting state must persist across reboots.
*
* The AdaptiveLightingController can be operated in two modes: {@link AdaptiveLightingControllerMode.AUTOMATIC} and
* {@link AdaptiveLightingControllerMode.MANUAL} with AUTOMATIC being the default.
* The goal would be that the color transition is done DIRECTLY on the light itself, thus not creating any
* additional/heavy traffic on the network.
* So if your light hardware/API supports transitions please go the extra mile and use MANUAL mode.
*
*
*
* Below is an overview what you need to or consider when enabling AdaptiveLighting (categorized by mode).
* The {@link AdaptiveLightingControllerMode} can be defined with the second constructor argument.
*
* <b>AUTOMATIC (Default mode):</b>
*
* This is the easiest mode to setup and needs less to no work form your side for AdaptiveLighting to work.
* The AdaptiveLightingController will go through setup procedure with HomeKit and automatically update
* the color temperature characteristic base on the current transition schedule.
* It is also adjusting the color temperature when a write to the brightness characteristic happens.
* Additionally, it will also handle turning off AdaptiveLighting, when it detects a write happening to the
* ColorTemperature, Hue or Saturation characteristic (though it can only detect writes coming from HomeKit and
* can't detect changes done to the physical devices directly! See below).
*
* So what do you need to consider in automatic mode:
* - Brightness and ColorTemperature characteristics MUST be set up. Hue and Saturation may be added for color support.
* - Color temperature will be updated all 60 seconds by calling the SET handler of the ColorTemperature characteristic.
* So every transition behaves like a regular write to the ColorTemperature characteristic.
* - Every transition step is dependent on the current brightness value. Try to keep the internal cache updated
* as the controller won't call the GET handler every 60 seconds.
* (The cached brightness value is updated on SET/GET operations or by manually calling {@link Characteristic.updateValue}
* on the brightness characteristic).
* - Detecting changes on the lightbulb side:
* Any manual change to ColorTemperature or Hue/Saturation is considered as a signal to turn AdaptiveLighting off.
* In order to notify the AdaptiveLightingController of such an event happening OUTSIDE of HomeKit
* you must call {@link disableAdaptiveLighting} manually!
* - Be aware that even when the light is turned off the transition will continue to call the SET handler
* of the ColorTemperature characteristic.
* - When using Hue/Saturation:
* When using Hue/Saturation in combination with the ColorTemperature characteristic you need to update the
* respective other in a particular way depending on if being in "color mode" or "color temperature mode".
* When a write happens to Hue/Saturation characteristic in is advised to set the internal value of the
* ColorTemperature to the minimal (NOT RAISING an event).
* When a write happens to the ColorTemperature characteristic just MUST convert to a proper representation
* in hue and saturation values, with RAISING an event.
* As noted above you MUST NOT call the {@link Characteristic.setValue} method for this, as this will be considered
* a write to the characteristic and will turn off AdaptiveLighting. Instead, you should use
* {@link Characteristic.updateValue} for this.
* You can and SHOULD use the supplied utility method {@link ColorUtils.colorTemperatureToHueAndSaturation}
* for converting mired to hue and saturation values.
*
*
* <b>MANUAL mode:</b>
*
* Manual mode is recommended for any accessories which support transitions natively on the devices end.
* Like for example ZigBee lights which support sending transitions directly to the lightbulb which
* then get executed ON the lightbulb itself reducing unnecessary network traffic.
* Here is a quick overview what you have to consider to successfully implement AdaptiveLighting support.
* The AdaptiveLightingController will also in manual mode do all the setup procedure.
* It will also save the transition schedule to disk to keep AdaptiveLighting enabled across reboots.
* The "only" thing you have to do yourself is handling the actual transitions, check that event notifications
* are only sent in the defined interval threshold, adjust the color temperature when brightness is changed
* and signal that Adaptive Lighting should be disabled if ColorTemperature, Hue or Saturation is changed manually.
*
* First step is to setup up an event handler for the {@link AdaptiveLightingControllerEvents.UPDATE}, which is called
* when AdaptiveLighting is enabled, the HomeHub updates the schedule for the next 24 hours or AdaptiveLighting
* is restored from disk on startup.
* In the event handler you can get the current schedule via {@link AdaptiveLightingController.getAdaptiveLightingTransitionCurve},
* retrieve current intervals like {@link AdaptiveLightingController.getAdaptiveLightingUpdateInterval} or
* {@link AdaptiveLightingController.getAdaptiveLightingNotifyIntervalThreshold} and get the date in epoch millis
* when the current transition curve started using {@link AdaptiveLightingController.getAdaptiveLightingStartTimeOfTransition}.
* Additionally {@link AdaptiveLightingController.getAdaptiveLightingBrightnessMultiplierRange} can be used
* to get the valid range for the brightness value to calculate the brightness adjustment factor.
* The method {@link AdaptiveLightingController.isAdaptiveLightingActive} can be used to check if AdaptiveLighting is enabled.
* Besides, actually running the transition (see {@link AdaptiveLightingTransitionCurveEntry}) you must correctly update
* the color temperature when the brightness of the lightbulb changes (see {@link AdaptiveLightingTransitionCurveEntry.brightnessAdjustmentFactor}),
* and signal when AdaptiveLighting got disabled by calling {@link AdaptiveLightingController.disableAdaptiveLighting}
* when ColorTemperature, Hue or Saturation where changed manually.
* Lastly you should set up a event handler for the {@link AdaptiveLightingControllerEvents.DISABLED} event.
* In yet unknown circumstances HomeKit may also send a dedicated disable command via the control point characteristic.
* Be prepared to handle that.
*
* @group Adaptive Lighting
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
class AdaptiveLightingController extends events_1.EventEmitter {
stateChangeDelegate;
lightbulb;
mode;
customTemperatureAdjustment;
adjustmentFactorChangedListener;
characteristicManualWrittenChangeListener;
supportedTransitionConfiguration;
transitionControl;
activeTransitionCount;
colorTemperatureCharacteristic;
brightnessCharacteristic;
saturationCharacteristic;
hueCharacteristic;
activeTransition;
didRunFirstInitializationStep = false;
updateTimeout;
lastTransitionPointInfo;
lastEventNotificationSent = 0;
lastNotifiedTemperatureValue = 0;
lastNotifiedSaturationValue = 0;
lastNotifiedHueValue = 0;
/**
* Creates a new instance of the AdaptiveLightingController.
* Refer to the {@link AdaptiveLightingController} documentation on how to use it.
*
* @param service - The lightbulb to which Adaptive Lighting support should be added.
* @param options - Optional options to define the operating mode (automatic vs manual).
*/
constructor(service, options) {
super();
this.lightbulb = service;
this.mode = options?.controllerMode ?? 1 /* AdaptiveLightingControllerMode.AUTOMATIC */;
this.customTemperatureAdjustment = options?.customTemperatureAdjustment ?? 0;
(0, assert_1.default)(this.lightbulb.testCharacteristic(Characteristic_1.Characteristic.ColorTemperature), "Lightbulb must have the ColorTemperature characteristic added!");
(0, assert_1.default)(this.lightbulb.testCharacteristic(Characteristic_1.Characteristic.Brightness), "Lightbulb must have the Brightness characteristic added!");
this.adjustmentFactorChangedListener = this.handleAdjustmentFactorChanged.bind(this);
this.characteristicManualWrittenChangeListener = this.handleCharacteristicManualWritten.bind(this);
}
/**
* @private
*/
controllerId() {
return "characteristic-transition" /* DefaultControllerType.CHARACTERISTIC_TRANSITION */ + "-" + this.lightbulb.getServiceId();
}
// ----------- PUBLIC API START -----------
/**
* Returns if a Adaptive Lighting transition is currently active.
*/
isAdaptiveLightingActive() {
return !!this.activeTransition;
}
/**
* This method can be called to manually disable the current active Adaptive Lighting transition.
* When using {@link AdaptiveLightingControllerMode.AUTOMATIC} you won't need to call this method.
* In {@link AdaptiveLightingControllerMode.MANUAL} you must call this method when Adaptive Lighting should be disabled.
* This is the case when the user manually changes the value of Hue, Saturation or ColorTemperature characteristics
* (or if any of those values is changed by physical interaction with the lightbulb).
*/
disableAdaptiveLighting() {
if (this.updateTimeout) {
clearTimeout(this.updateTimeout);
this.updateTimeout = undefined;
}
if (this.activeTransition) {
this.colorTemperatureCharacteristic?.removeListener("change" /* CharacteristicEventTypes.CHANGE */, this.characteristicManualWrittenChangeListener);
this.brightnessCharacteristic?.removeListener("change" /* CharacteristicEventTypes.CHANGE */, this.adjustmentFactorChangedListener);
this.hueCharacteristic?.removeListener("change" /* CharacteristicEventTypes.CHANGE */, this.characteristicManualWrittenChangeListener);
this.saturationCharacteristic?.removeListener("change" /* CharacteristicEventTypes.CHANGE */, this.characteristicManualWrittenChangeListener);
this.activeTransition = undefined;
this.stateChangeDelegate?.();
}
this.colorTemperatureCharacteristic = undefined;
this.brightnessCharacteristic = undefined;
this.hueCharacteristic = undefined;
this.saturationCharacteristic = undefined;
this.lastTransitionPointInfo = undefined;
this.lastEventNotificationSent = 0;
this.lastNotifiedTemperatureValue = 0;
this.lastNotifiedSaturationValue = 0;
this.lastNotifiedHueValue = 0;
this.didRunFirstInitializationStep = false;
this.activeTransitionCount?.sendEventNotification(0);
debug("[%s] Disabling adaptive lighting", this.lightbulb.displayName);
}
/**
* Returns the time where the current transition curve was started in epoch time millis.
* A transition curves is active for 24 hours typically and is renewed every 24 hours by a HomeHub.
* Additionally see {@link getAdaptiveLightingTimeOffset}.
*/
getAdaptiveLightingStartTimeOfTransition() {
if (!this.activeTransition) {
throw new Error("There is no active transition!");
}
return this.activeTransition.transitionStartMillis;
}
/**
* It is not necessarily given, that we have the same time (or rather the correct time) as the HomeKit controller
* who set up the transition schedule.
* Thus we record the delta between our current time and the the time send with the setup request.
* <code>timeOffset</code> is defined as <code>Date.now() - getAdaptiveLightingStartTimeOfTransition();</code>.
* So in the case were we actually have a correct local time, it most likely will be positive (due to network latency).
* But of course it can also be negative.
*/
getAdaptiveLightingTimeOffset() {
if (!this.activeTransition) {
throw new Error("There is no active transition!");
}
return this.activeTransition.timeMillisOffset;
}
getAdaptiveLightingTransitionCurve() {
if (!this.activeTransition) {
throw new Error("There is no active transition!");
}
return this.activeTransition.transitionCurve;
}
getAdaptiveLightingBrightnessMultiplierRange() {
if (!this.activeTransition) {
throw new Error("There is no active transition!");
}
return this.activeTransition.brightnessAdjustmentRange;
}
/**
* This method returns the interval (in milliseconds) in which the light should update its internal color temperature
* (aka changes it physical color).
* A lightbulb should ideally change this also when turned of in oder to have a smooth transition when turning the light on.
*
* Typically this evaluates to 60000 milliseconds (60 seconds).
*/
getAdaptiveLightingUpdateInterval() {
if (!this.activeTransition) {
throw new Error("There is no active transition!");
}
return this.activeTransition.updateInterval;
}
/**
* Returns the minimum interval threshold (in milliseconds) a accessory may notify HomeKit controllers about a new
* color temperature value via event notifications (what happens when you call {@link Characteristic.updateValue}).
* Meaning the accessory should only send event notifications to subscribed HomeKit controllers at the specified interval.
*
* Typically this evaluates to 600000 milliseconds (10 minutes).
*/
getAdaptiveLightingNotifyIntervalThreshold() {
if (!this.activeTransition) {
throw new Error("There is no active transition!");
}
return this.activeTransition.notifyIntervalThreshold;
}
// ----------- PUBLIC API END -----------
handleActiveTransitionUpdated(calledFromDeserializer = false) {
if (this.activeTransitionCount) {
if (!calledFromDeserializer) {
this.activeTransitionCount.sendEventNotification(1);
}
else {
this.activeTransitionCount.value = 1;
}
}
if (this.mode === 1 /* AdaptiveLightingControllerMode.AUTOMATIC */) {
this.scheduleNextUpdate();
}
else if (this.mode === 2 /* AdaptiveLightingControllerMode.MANUAL */) {
if (!this.activeTransition) {
throw new Error("There is no active transition!");
}
const update = {
transitionStartMillis: this.activeTransition.transitionStartMillis,
timeMillisOffset: this.activeTransition.timeMillisOffset,
transitionCurve: this.activeTransition.transitionCurve,
brightnessAdjustmentRange: this.activeTransition.brightnessAdjustmentRange,
updateInterval: this.activeTransition.updateInterval,
notifyIntervalThreshold: this.activeTransition.notifyIntervalThreshold,
};
this.emit("update" /* AdaptiveLightingControllerEvents.UPDATE */, update);
}
else {
throw new Error("Unsupported adaptive lighting controller mode: " + this.mode);
}
if (!calledFromDeserializer) {
this.stateChangeDelegate?.();
}
}
handleAdaptiveLightingEnabled() {
if (!this.activeTransition) {
throw new Error("There is no active transition!");
}
this.colorTemperatureCharacteristic = this.lightbulb.getCharacteristic(Characteristic_1.Characteristic.ColorTemperature);
this.brightnessCharacteristic = this.lightbulb.getCharacteristic(Characteristic_1.Characteristic.Brightness);
this.colorTemperatureCharacteristic.on("change" /* CharacteristicEventTypes.CHANGE */, this.characteristicManualWrittenChangeListener);
this.brightnessCharacteristic.on("change" /* CharacteristicEventTypes.CHANGE */, this.adjustmentFactorChangedListener);
if (this.lightbulb.testCharacteristic(Characteristic_1.Characteristic.Hue)) {
this.hueCharacteristic = this.lightbulb.getCharacteristic(Characteristic_1.Characteristic.Hue)
.on("change" /* CharacteristicEventTypes.CHANGE */, this.characteristicManualWrittenChangeListener);
}
if (this.lightbulb.testCharacteristic(Characteristic_1.Characteristic.Saturation)) {
this.saturationCharacteristic = this.lightbulb.getCharacteristic(Characteristic_1.Characteristic.Saturation)
.on("change" /* CharacteristicEventTypes.CHANGE */, this.characteristicManualWrittenChangeListener);
}
}
handleAdaptiveLightingDisabled() {
if (this.mode === 2 /* AdaptiveLightingControllerMode.MANUAL */ && this.activeTransition) { // only emit the event if a transition is actually enabled
this.emit("disable" /* AdaptiveLightingControllerEvents.DISABLED */);
}
this.disableAdaptiveLighting();
}
handleAdjustmentFactorChanged(change) {
if (change.newValue === change.oldValue) {
return;
}
// consider the following scenario:
// a HomeKit controller queries the light (meaning e.g. Brightness, Hue and Saturation characteristics).
// As of the implementation of the light the brightness characteristic get handler returns first
// (and returns a value different than the cached value).
// This change handler gets called and we will update the color temperature accordingly
// (which also adjusts the internal cached values for Hue and Saturation).
// After some short time the Hue or Saturation get handler return with the last known value to the plugin.
// As those values now differ from the cached values (we already updated) we get a call to handleCharacteristicManualWritten
// which again disables adaptive lighting.
if (change.reason === "read" /* ChangeReason.READ */) {
// if the reason is a read request, we expect that Hue/Saturation are also read
// thus we postpone our update to ColorTemperature a bit.
// It doesn't ensure that those race conditions do not happen anymore, but with a 1s delay it reduces the possibility by a bit
setTimeout(() => {
if (!this.activeTransition) {
return; // was disabled in the mean time
}
this.scheduleNextUpdate(true);
}, 1000).unref();
}
else {
this.scheduleNextUpdate(true); // run a dry scheduleNextUpdate to adjust the colorTemperature using the new brightness value
}
}
/**
* This method is called when a change happens to the Hue/Saturation or ColorTemperature characteristic.
* When such a write happens (caused by the user changing the color/temperature) Adaptive Lighting must be disabled.
*
* @param change
*/
handleCharacteristicManualWritten(change) {
if (change.reason === "write" /* ChangeReason.WRITE */ && !(isAdaptiveLightingContext(change.context) && change.context.controller === this)) {
// we ignore write request which are the result of calls made to updateValue or sendEventNotification
// or the result of a changed value returned by a read handler
// or the change was done by the controller itself
debug("[%s] Received a manual write to an characteristic (newValue: %d, oldValue: %d, reason: %s). Thus disabling adaptive lighting!", this.lightbulb.displayName, change.newValue, change.oldValue, change.reason);
this.disableAdaptiveLighting();
}
}
/**
* Retrieve the {@link AdaptiveLightingTransitionPoint} for the current timestamp.
* Returns undefined if the current transition schedule reached its end.
*/
getCurrentAdaptiveLightingTransitionPoint() {
if (!this.activeTransition) {
throw new Error("Cannot calculate current transition point if no transition is active!");
}
// adjustedNow is the now() date corrected to the time of the initiating controller
const adjustedNow = Date.now() - this.activeTransition.timeMillisOffset;
// "offset" since the start of the transition schedule
const offset = adjustedNow - this.activeTransition.transitionStartMillis;
let i = this.lastTransitionPointInfo?.curveIndex ?? 0;
let lowerBoundTimeOffset = this.lastTransitionPointInfo?.lowerBoundTimeOffset ?? 0; // time offset to the lowerBound transition entry
let lowerBound = undefined;
let upperBound = undefined;
for (; i + 1 < this.activeTransition.transitionCurve.length; i++) {
const lowerBound0 = this.activeTransition.transitionCurve[i];
const upperBound0 = this.activeTransition.transitionCurve[i + 1];
const lowerBoundDuration = lowerBound0.duration ?? 0;
lowerBoundTimeOffset += lowerBound0.transitionTime;
if (offset >= lowerBoundTimeOffset) {
if (offset <= lowerBoundTimeOffset + lowerBoundDuration + upperBound0.transitionTime) {
lowerBound = lowerBound0;
upperBound = upperBound0;
break;
}
}
else if (this.lastTransitionPointInfo) {
// if we reached here the entry in the transitionCurve we are searching for is somewhere before current i.
// This can only happen when we have a faulty lastTransitionPointInfo (otherwise we would start from i=0).
// Thus we try again by searching from i=0
this.lastTransitionPointInfo = undefined;
return this.getCurrentAdaptiveLightingTransitionPoint();
}
lowerBoundTimeOffset += lowerBoundDuration;
}
if (!lowerBound || !upperBound) {
this.lastTransitionPointInfo = undefined;
return undefined;
}
this.lastTransitionPointInfo = {
curveIndex: i,
// we need to subtract lowerBound.transitionTime. When we start the loop above
// with a saved transition point, we will always add lowerBound.transitionTime as first step.
// Otherwise our calculations are simply wrong.
lowerBoundTimeOffset: lowerBoundTimeOffset - lowerBound.transitionTime,
};
return {
lowerBoundTimeOffset: lowerBoundTimeOffset,
transitionOffset: offset - lowerBoundTimeOffset,
lowerBound: lowerBound,
upperBound: upperBound,
};
}
scheduleNextUpdate(dryRun = false) {
if (!this.activeTransition) {
throw new Error("tried scheduling transition when no transition was active!");
}
if (!dryRun) {
this.updateTimeout = undefined;
}
if (!this.didRunFirstInitializationStep) {
this.didRunFirstInitializationStep = true;
this.handleAdaptiveLightingEnabled();
}
const transitionPoint = this.getCurrentAdaptiveLightingTransitionPoint();
if (!transitionPoint) {
debug("[%s] Reached end of transition curve!", this.lightbulb.displayName);
if (!dryRun) {
// the transition schedule is only for 24 hours, we reached the end?
this.disableAdaptiveLighting();
}
return;
}
const lowerBound = transitionPoint.lowerBound;
const upperBound = transitionPoint.upperBound;
let interpolatedTemperature;
let interpolatedAdjustmentFactor;
if (lowerBound.duration && transitionPoint.transitionOffset <= lowerBound.duration) {
interpolatedTemperature = lowerBound.temperature;
interpolatedAdjustmentFactor = lowerBound.brightnessAdjustmentFactor;
}
else {
const timePercentage = (transitionPoint.transitionOffset - (lowerBound.duration ?? 0)) / upperBound.transitionTime;
interpolatedTemperature = lowerBound.temperature + (upperBound.temperature - lowerBound.temperature) * timePercentage;
interpolatedAdjustmentFactor = lowerBound.brightnessAdjustmentFactor
+ (upperBound.brightnessAdjustmentFactor - lowerBound.brightnessAdjustmentFactor) * timePercentage;
}
const adjustmentMultiplier = Math.max(this.activeTransition.brightnessAdjustmentRange.minBrightnessValue, Math.min(this.activeTransition.brightnessAdjustmentRange.maxBrightnessValue, this.brightnessCharacteristic?.value));
let temperature = Math.round(interpolatedTemperature + interpolatedAdjustmentFactor * adjustmentMultiplier);
// apply any manually applied temperature adjustments
temperature += this.customTemperatureAdjustment;
const min = this.colorTemperatureCharacteristic?.props.minValue ?? 140;
const max = this.colorTemperatureCharacteristic?.props.maxValue ?? 500;
temperature = Math.max(min, Math.min(max, temperature));
const color = color_utils_1.ColorUtils.colorTemperatureToHueAndSaturation(temperature);
debug("[%s] Next temperature value is %d (for brightness %d adj: %s)", this.lightbulb.displayName, temperature, adjustmentMultiplier, this.customTemperatureAdjustment);
const context = {
controller: this,
omitEventUpdate: true,
};
/*
* We set saturation and hue values BEFORE we call the ColorTemperature SET handler (via setValue).
* First thought was so the API user could get the values in the SET handler of the color temperature characteristic.
* Do this is probably not really elegant cause this would only work when Adaptive Lighting is turned on
* an the accessory MUST in any case update the Hue/Saturation values on a ColorTemperature write
* (obviously only if Hue/Saturation characteristics are added to the service).
*
* The clever thing about this though is that, that it prevents notifications from being sent for Hue and Saturation
* outside the specified notifyIntervalThreshold (see below where notifications are manually sent).
* As the dev will or must call something like updateValue to propagate the updated hue and saturation values
* to all HomeKit clients (so that the color is reflected in the UI), HAP-NodeJS won't send notifications
* as the values are the same.
* This of course only works if the plugin uses the exact same algorithm of "converting" the color temperature
* value to the hue and saturation representation.
*/
if (this.saturationCharacteristic) {
this.saturationCharacteristic.value = color.saturation;
}
if (this.hueCharacteristic) {
this.hueCharacteristic.value = color.hue;
}
this.colorTemperatureCharacteristic?.handleSetRequest(temperature, undefined, context).catch(reason => {
debug("[%s] Failed to next adaptive lighting transition point: %d", this.lightbulb.displayName, reason);
});
if (!this.activeTransition) {
console.warn("[" + this.lightbulb.displayName + "] Adaptive Lighting was probably disable my mistake by some call in " +
"the SET handler of the ColorTemperature characteristic! " +
"Please check that you don't call setValue/setCharacteristic on the Hue, Saturation or ColorTemperature characteristic!");
return;
}
const now = Date.now();
if (!dryRun && now - this.lastEventNotificationSent >= this.activeTransition.notifyIntervalThreshold) {
debug("[%s] Sending event notifications for current transition!", this.lightbulb.displayName);
this.lastEventNotificationSent = now;
const eventContext = {
controller: this,
};
if (this.lastNotifiedTemperatureValue !== temperature) {
this.colorTemperatureCharacteristic?.sendEventNotification(temperature, eventContext);
this.lastNotifiedTemperatureValue = temperature;
}
if (this.saturationCharacteristic && this.lastNotifiedSaturationValue !== color.saturation) {
this.saturationCharacteristic.sendEventNotification(color.saturation, eventContext);
this.lastNotifiedSaturationValue = color.saturation;
}
if (this.hueCharacteristic && this.lastNotifiedHueValue !== color.hue) {
this.hueCharacteristic.sendEventNotification(color.hue, eventContext);
this.lastNotifiedHueValue = color.hue;
}
}
if (!dryRun) {
this.updateTimeout = setTimeout(this.scheduleNextUpdate.bind(this), this.activeTransition.updateInterval);
}
}
/**
* @private
*/
constructServices() {
return {};
}
/**
* @private
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
initWithServices(serviceMap) {
// do nothing
}
/**
* @private
*/
configureServices() {
this.supportedTransitionConfiguration = this.lightbulb.getCharacteristic(Characteristic_1.Characteristic.SupportedCharacteristicValueTransitionConfiguration);
this.transitionControl = this.lightbulb.getCharacteristic(Characteristic_1.Characteristic.CharacteristicValueTransitionControl)
.updateValue("");
this.activeTransitionCount = this.lightbulb.getCharacteristic(Characteristic_1.Characteristic.CharacteristicValueActiveTransitionCount)
.updateValue(0);
this.supportedTransitionConfiguration
.onGet(this.handleSupportedTransitionConfigurationRead.bind(this));
this.transitionControl
.onGet(() => {
return this.buildTransitionControlResponseBuffer().toString("base64");
})
.onSet(value => {
try {
return this.handleTransitionControlWrite(value);
}
catch (error) {
console.warn(`[%s] DEBUG: '${value}'`);
console.warn("[%s] Encountered error on CharacteristicValueTransitionControl characteristic: " + error.stack);
this.disableAdaptiveLighting();
throw new hapStatusError_1.HapStatusError(-70402 /* HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
}
});
}
/**
* @private
*/
handleControllerRemoved() {
this.lightbulb.removeCharacteristic(this.supportedTransitionConfiguration);
this.lightbulb.removeCharacteristic(this.transitionControl);
this.lightbulb.removeCharacteristic(this.activeTransitionCount);
this.supportedTransitionConfiguration = undefined;
this.transitionControl = undefined;
this.activeTransitionCount = undefined;
this.removeAllListeners();
}
/**
* @private
*/
handleFactoryReset() {
this.handleAdaptiveLightingDisabled();
}
/**
* @private
*/
serialize() {
if (!this.activeTransition) {
return undefined;
}
return {
activeTransition: this.activeTransition,
};
}
/**
* @private
*/
deserialize(serialized) {
this.activeTransition = serialized.activeTransition;
// Data migrations from beta builds
if (!this.activeTransition.transitionId) {
// @ts-expect-error: data migration from beta builds
this.activeTransition.transitionId = this.activeTransition.id1;
// @ts-expect-error: data migration from beta builds
delete this.activeTransition.id1;
}
if (!this.activeTransition.timeMillisOffset) { // compatibility to data produced by early betas
this.activeTransition.timeMillisOffset = 0;
}
this.handleActiveTransitionUpdated(true);
}
/**
* @private
*/
setupStateChangeDelegate(delegate) {
this.stateChangeDelegate = delegate;
}
handleSupportedTransitionConfigurationRead() {
const brightnessIID = this.lightbulb?.getCharacteristic(Characteristic_1.Characteristic.Brightness).iid;
const temperatureIID = this.lightbulb?.getCharacteristic(Characteristic_1.Characteristic.ColorTemperature).iid;
(0, assert_1.default)(brightnessIID, "iid for brightness characteristic is undefined");
(0, assert_1.default)(temperatureIID, "iid for temperature characteristic is undefined");
return tlv.encode(1 /* SupportedCharacteristicValueTransitionConfigurationsTypes.SUPPORTED_TRANSITION_CONFIGURATION */, [
tlv.encode(1 /* SupportedValueTransitionConfigurationTypes.CHARACTERISTIC_IID */, tlv.writeVariableUIntLE(brightnessIID), 2 /* SupportedValueTransitionConfigurationTypes.TRANSITION_TYPE */, 1 /* TransitionType.BRIGHTNESS */),
tlv.encode(1 /* SupportedValueTransitionConfigurationTypes.CHARACTERISTIC_IID */, tlv.writeVariableUIntLE(temperatureIID), 2 /* SupportedValueTransitionConfigurationTypes.TRANSITION_TYPE */, 2 /* TransitionType.COLOR_TEMPERATURE */),
]).toString("base64");
}
buildTransitionControlResponseBuffer(time) {
if (!this.activeTransition) {
return Buffer.alloc(0);
}
const active = this.activeTransition;
const timeSinceStart = time ?? (Date.now() - active.timeMillisOffset - active.transitionStartMillis);
const timeSinceStartBuffer = tlv.writeVariableUIntLE(timeSinceStart);
let parameters = tlv.encode(1 /* ValueTransitionParametersTypes.TRANSITION_ID */, uuid.write(active.transitionId), 2 /* ValueTransitionParametersTypes.START_TIME */, Buffer.from(active.transitionStartBuffer, "hex"));
if (active.id3) {
parameters = Buffer.concat([
parameters,
tlv.encode(3 /* ValueTransitionParametersTypes.UNKNOWN_3 */, Buffer.from(active.id3, "hex")),
]);
}
const status = tlv.encode(1 /* ValueTransitionConfigurationStatusTypes.CHARACTERISTIC_IID */, tlv.writeVariableUIntLE(active.iid), 2 /* ValueTransitionConfigurationStatusTypes.TRANSITION_PARAMETERS */, parameters, 3 /* ValueTransitionConfigurationStatusTypes.TIME_SINCE_START */, timeSinceStartBuffer);
return tlv.encode(1 /* ValueTransitionConfigurationResponseTypes.VALUE_CONFIGURATION_STATUS */, status);
}
handleTransitionControlWrite(value) {
if (typeof value !== "string") {
throw new hapStatusError_1.HapStatusError(-70410 /* HAPStatus.INVALID_VALUE_IN_REQUEST */);
}
const tlvData = tlv.decode(Buffer.from(value, "base64"));
const responseBuffers = [];
const readTransition = tlvData[1 /* TransitionControlTypes.READ_CURRENT_VALUE_TRANSITION_CONFIGURATION */];
if (readTransition) {
const readTransitionResponse = this.handleTransitionControlReadTransition(readTransition);
if (readTransitionResponse) {
responseBuffers.push(readTransitionResponse);
}
}
const updateTransition = tlvData[2 /* TransitionControlTypes.UPDATE_VALUE_TRANSITION_CONFIGURATION */];
if (updateTransition) {
const updateTransitionResponse = this.handleTransitionControlUpdateTransition(updateTransition);
if (updateTransitionResponse) {
responseBuffers.push(updateTransitionResponse);
}
}
return Buffer.concat(responseBuffers).toString("base64");
}
handleTransitionControlReadTransition(buffer) {
const readTransition = tlv.decode(buffer);
const iid = tlv.readVariableUIntLE(readTransition[1 /* ReadValueTransitionConfiguration.CHARACTERISTIC_IID */]);
if (this.activeTransition) {
if (this.activeTransition.iid !== iid) {
console.warn("[" + this.lightbulb.displayName + "] iid of current adaptive lighting transition (" + this.activeTransition.iid
+ ") doesn't match the requested one " + iid);
throw new hapStatusError_1.HapStatusError(-70410 /* HAPStatus.INVALID_VALUE_IN_REQUEST */);
}
let parameters = tlv.encode(1 /* ValueTransitionParametersTypes.TRANSITION_ID */, uuid.write(this.activeTransition.transitionId), 2 /* ValueTransitionParametersTypes.START_TIME */, Buffer.from(this.activeTransition.transitionStartBuffer, "hex"));
if (this.activeTransition.id3) {
parameters = Buffer.concat([
parameters,
tlv.encode(3 /* ValueTransitionParametersTypes.UNKNOWN_3 */, Buffer.from(this.activeTransition.id3, "hex")),
]);
}
return tlv.encode(1 /* TransitionControlTypes.READ_CURRENT_VALUE_TRANSITION_CONFIGURATION */, tlv.encode(1 /* ValueTransitionConfigurationTypes.CHARACTERISTIC_IID */, tlv.writeVariableUIntLE(this.activeTransition.iid), 2 /* ValueTransitionConfigurationTypes.TRANSITION_PARAMETERS */, parameters, 3 /* ValueTransitionConfigurationTypes.UNKNOWN_3 */, 1, 5 /* ValueTransitionConfigurationTypes.TRANSITION_CURVE_CONFIGURATION */, tlv.encode(1 /* TransitionCurveConfigurationTypes.TRANSITION_ENTRY */, this.activeTransition.transitionCurve.map((entry, index, array) => {
const duration = array[index - 1]?.duration ?? 0; // we store stuff differently :sweat_smile:
return tlv.encode(1 /* TransitionEntryTypes.ADJUSTMENT_FACTOR */, tlv.writeFloat32LE(entry.brightnessAdjustmentFactor), 2 /* TransitionEntryTypes.VALUE */, tlv.writeFloat32LE(entry.temperature), 3 /* TransitionEntryTypes.TRANSITION_OFFSET */, tlv.writeVariableUIntLE(entry.transitionTime), 4 /* TransitionEntryTypes.DURATION */, tlv.writeVariableUIntLE(duration));
}), 2 /* TransitionCurveConfigurationTypes.ADJUSTMENT_CHARACTERISTIC_IID */, tlv.writeVariableUIntLE(this.activeTransition.brightnessCharacteristicIID), 3 /* TransitionCurveConfigurationTypes.ADJUSTMENT_MULTIPLIER_RANGE */, tlv.encode(1 /* TransitionAdjustmentMultiplierRange.MINIMUM_ADJUSTMENT_MULTIPLIER */, tlv.writeUInt32(this.activeTransition.brightnessAdjustmentRange.minBrightnessValue), 2 /* TransitionAdjustmentMultiplierRange.MAXIMUM_ADJUSTMENT_MULTIPLIER */, tlv.writeUInt32(this.activeTransition.brightnessAdjustmentRange.maxBrightnessValue))), 6 /* ValueTransitionConfigurationTypes.UPDATE_INTERVAL */, tlv.writeVariableUIntLE(this.activeTransition.updateInterval), 8 /* ValueTransitionConfigurationTypes.NOTIFY_INTERVAL_THRESHOLD */, tlv.writeVariableUIntLE(this.activeTransition.notifyIntervalThreshold)));
}
else {
return undefined; // returns empty string
}
}
handleTransitionControlUpdateTransition(buffer) {
const updateTransition = tlv.decode(buffer);
const transitionConfiguration = tlv.decode(updateTransition[1 /* UpdateValueTransitionConfigurationsTypes.VALUE_TRANSITION_CONFIGURATION */]);
const iid = tlv.readVariableUIntLE(transitionConfiguration[1 /* ValueTransitionConfigurationTypes.CHARACTERISTIC_IID */]);
if (!this.lightbulb.getCharacteristicByIID(iid)) {
throw new hapStatusError_1.HapStatusError(-70410 /* HAPStatus.INVALID_VALUE_IN_REQUEST */);
}
const param3 = transitionConfi