homebridge-z2m
Version:
Expose your Zigbee devices to HomeKit with ease, by integrating Zigbee2MQTT with Homebridge.
512 lines • 26.7 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.LightCreator = void 0;
const colorhelper_1 = require("../colorhelper");
const hap_1 = require("../hap");
const helpers_1 = require("../helpers");
const z2mModels_1 = require("../z2mModels");
const monitor_1 = require("./monitor");
const isAdaptiveLightingConfig = (x) => {
if (x === null || typeof x !== 'object') {
return false;
}
const config = x;
if (config.enabled !== undefined && typeof config.enabled !== 'boolean') {
return false;
}
if (config.only_when_on !== undefined && typeof config.only_when_on !== 'boolean') {
return false;
}
if (config.transition !== undefined && typeof config.transition !== 'number') {
return false;
}
if (config.min_delta !== undefined) {
if (typeof config.min_delta !== 'number' || config.min_delta < 1) {
return false;
}
}
return true;
};
const isLightConfig = (x) => x !== undefined &&
(x.adaptive_lighting === undefined ||
typeof x.adaptive_lighting === 'boolean' ||
isAdaptiveLightingConfig(x.adaptive_lighting));
class LightCreator {
static CONFIG_TAG = 'light';
static ADAPTIVE_LIGHTING_DEFAULT_CONFIG = {
enabled: true,
only_when_on: true,
transition: undefined,
min_delta: 1,
};
constructor(converterConfigRegistry) {
converterConfigRegistry.registerConverterConfiguration(LightCreator.CONFIG_TAG, LightCreator.isValidConverterConfiguration);
}
createServicesFromExposes(accessory, exposes) {
exposes
.filter((e) => e.type === z2mModels_1.ExposesKnownTypes.LIGHT &&
(0, z2mModels_1.exposesHasFeatures)(e) &&
(0, z2mModels_1.exposesHasAllRequiredFeatures)(e, [LightHandler.PREDICATE_STATE]) &&
!accessory.isServiceHandlerIdKnown(LightHandler.generateIdentifier(e.endpoint)))
.forEach((e) => this.createService(e, accessory));
}
createService(expose, accessory) {
const converterConfig = accessory.getConverterConfiguration(LightCreator.CONFIG_TAG);
let requestBrightness = false;
// Adaptive Lighting is enabled by default
let adaptiveLightingConfig = { ...LightCreator.ADAPTIVE_LIGHTING_DEFAULT_CONFIG };
if (isLightConfig(converterConfig)) {
requestBrightness = !!converterConfig.request_brightness;
if (converterConfig.adaptive_lighting === false) {
// Explicitly disabled
adaptiveLightingConfig = { ...adaptiveLightingConfig, enabled: false };
}
else if (isAdaptiveLightingConfig(converterConfig.adaptive_lighting)) {
// Merge user config with defaults
adaptiveLightingConfig = { ...adaptiveLightingConfig, ...converterConfig.adaptive_lighting };
}
// Note: if adaptive_lighting === true, we just use defaults (already enabled)
}
try {
const handler = new LightHandler(expose, accessory, requestBrightness, adaptiveLightingConfig);
accessory.registerServiceHandler(handler);
}
catch (error) {
accessory.log.warn(`Failed to setup light for accessory ${accessory.displayName} from expose "${JSON.stringify(expose)}": ${error}`);
}
}
static isValidConverterConfiguration(config) {
return isLightConfig(config);
}
}
exports.LightCreator = LightCreator;
class LightHandler {
accessory;
requestBrightness;
adaptiveLightingConfig;
static PREDICATE_STATE = (e) => e.name === 'state' && (0, z2mModels_1.exposesIsPublished)(e) && (0, z2mModels_1.exposesCanBeSet)(e) && (0, z2mModels_1.exposesHasBinaryProperty)(e);
static KEY_COLOR_MODE = 'color_mode';
static COLOR_MODE_TEMPERATURE = 'color_temp';
mainCharacteristics;
service;
monitors = [];
stateExpose;
brightnessExpose;
colorTempExpose;
colorExpose;
colorComponentAExpose;
colorComponentBExpose;
// Adaptive lighting
adaptiveLighting;
lastAdaptiveLightingTemperature;
colorHueCharacteristic;
colorSaturationCharacteristic;
// Internal cache for hue and saturation. Needed in case X/Y is used
cached_hue = 0.0;
received_hue = false;
cached_saturation = 0.0;
received_saturation = false;
get adaptiveLightingEnabled() {
return this.adaptiveLightingConfig.enabled === true;
}
constructor(expose, accessory, requestBrightness, adaptiveLightingConfig) {
this.accessory = accessory;
this.requestBrightness = requestBrightness;
this.adaptiveLightingConfig = adaptiveLightingConfig;
const endpoint = expose.endpoint;
this.identifier = LightHandler.generateIdentifier(endpoint);
const features = expose.features.filter((e) => (0, z2mModels_1.exposesHasProperty)(e)).map((e) => e);
// On/off characteristic (required by HomeKit)
const potentialStateExpose = features.find((e) => LightHandler.PREDICATE_STATE(e));
if (potentialStateExpose === undefined) {
throw new Error('Required "state" property not found for Light.');
}
this.stateExpose = potentialStateExpose;
const serviceName = accessory.getDefaultServiceDisplayName(endpoint);
accessory.log.debug(`Configuring Light for ${serviceName}`);
this.service = accessory.getOrAddService(new hap_1.hap.Service.Lightbulb(serviceName, endpoint));
this.mainCharacteristics = [(0, helpers_1.getOrAddCharacteristic)(this.service, hap_1.hap.Characteristic.On).on('set', this.handleSetOn.bind(this))];
const onOffValues = new Map();
onOffValues.set(this.stateExpose.value_on, true);
onOffValues.set(this.stateExpose.value_off, false);
this.monitors.push(new monitor_1.MappingCharacteristicMonitor(this.stateExpose.property, this.service, hap_1.hap.Characteristic.On, onOffValues));
// Brightness characteristic
this.tryCreateBrightness(features, this.service);
// Color: Hue/Saturation or X/Y
this.tryCreateColor(expose, this.service);
// Color temperature
this.tryCreateColorTemperature(features, this.service);
// Adaptive lighting
this.tryCreateAdaptiveLighting(this.service);
}
identifier;
get getableKeys() {
const keys = [];
if ((0, z2mModels_1.exposesCanBeGet)(this.stateExpose)) {
keys.push(this.stateExpose.property);
}
if (this.brightnessExpose !== undefined && (0, z2mModels_1.exposesCanBeGet)(this.brightnessExpose) && this.requestBrightness) {
keys.push(this.brightnessExpose.property);
}
if (this.colorTempExpose !== undefined && (0, z2mModels_1.exposesCanBeGet)(this.colorTempExpose)) {
keys.push(this.colorTempExpose.property);
}
if (this.colorExpose?.property !== undefined &&
((this.colorComponentAExpose !== undefined && (0, z2mModels_1.exposesCanBeGet)(this.colorComponentAExpose)) ||
(this.colorComponentBExpose !== undefined && (0, z2mModels_1.exposesCanBeGet)(this.colorComponentBExpose)))) {
keys.push(this.colorExpose.property);
}
return keys;
}
updateState(state) {
if (LightHandler.KEY_COLOR_MODE in state) {
const colorModeIsTemperature = state[LightHandler.KEY_COLOR_MODE] === LightHandler.COLOR_MODE_TEMPERATURE;
// If adaptive lighting is enabled, try to detect if the color was changed externally
// which should result in turning off adaptive lighting.
this.disableAdaptiveLightingBasedOnState(colorModeIsTemperature, state);
// Use color_mode to filter out the non-active color information
// to prevent "incorrect" updates (leading to "glitches" in the Home.app)
if (this.colorTempExpose !== undefined && this.colorTempExpose.property in state && !colorModeIsTemperature) {
// Color mode is NOT Color Temperature. Remove color temperature information.
delete state[this.colorTempExpose.property];
}
if (this.colorExpose?.property !== undefined && this.colorExpose.property in state && colorModeIsTemperature) {
// Color mode is Color Temperature. Remove HS/XY color information.
delete state[this.colorExpose.property];
}
}
this.monitors.forEach((m) => m.callback(state, this.accessory.log));
}
disableAdaptiveLightingBasedOnState(colorModeIsTemperature, state) {
if (this.colorTempExpose !== undefined && this.adaptiveLighting !== undefined && this.adaptiveLighting.isAdaptiveLightingActive()) {
if (!colorModeIsTemperature) {
// Must be color temperature if adaptive lighting is active
this.accessory.log.debug('adaptive_lighting: disable due to color mode change');
this.adaptiveLighting.disableAdaptiveLighting();
}
else if (this.lastAdaptiveLightingTemperature !== undefined && this.colorTempExpose.property in state) {
const delta = Math.abs(this.lastAdaptiveLightingTemperature - state[this.colorTempExpose.property]);
// Typically we expect a small delta if the status update is caused by a change from adaptive lighting.
if (delta > 10) {
this.accessory.log.debug(`adaptive_lighting: disable due to large delta (${delta})`);
this.adaptiveLighting.disableAdaptiveLighting();
}
}
}
}
tryCreateColor(expose, service) {
// First see if color_hs is present
this.colorExpose = expose.features.find((e) => (0, z2mModels_1.exposesHasFeatures)(e) && e.type === z2mModels_1.ExposesKnownTypes.COMPOSITE && e.name === 'color_hs' && e.property !== undefined);
// Otherwise check for color_xy
if (this.colorExpose === undefined) {
this.colorExpose = expose.features.find((e) => (0, z2mModels_1.exposesHasFeatures)(e) && e.type === z2mModels_1.ExposesKnownTypes.COMPOSITE && e.name === 'color_xy' && e.property !== undefined);
}
if (this.colorExpose?.property !== undefined) {
// Note: Components of color_xy and color_hs do not specify a range in zigbee-herdsman-converters
const components = this.colorExpose.features
.filter((e) => (0, z2mModels_1.exposesHasProperty)(e) && e.type === z2mModels_1.ExposesKnownTypes.NUMERIC)
.map((e) => e);
this.colorComponentAExpose = undefined;
this.colorComponentBExpose = undefined;
if (this.colorExpose.name === 'color_hs') {
this.colorComponentAExpose = components.find((e) => e.name === 'hue');
this.colorComponentBExpose = components.find((e) => e.name === 'saturation');
}
else if (this.colorExpose.name === 'color_xy') {
this.colorComponentAExpose = components.find((e) => e.name === 'x');
this.colorComponentBExpose = components.find((e) => e.name === 'y');
}
if (this.colorComponentAExpose === undefined || this.colorComponentBExpose === undefined) {
// Can't create service if not all components are present.
this.colorExpose = undefined;
return;
}
this.colorHueCharacteristic = (0, helpers_1.getOrAddCharacteristic)(service, hap_1.hap.Characteristic.Hue).on('set', this.handleSetHue.bind(this));
this.colorSaturationCharacteristic = (0, helpers_1.getOrAddCharacteristic)(service, hap_1.hap.Characteristic.Saturation).on('set', this.handleSetSaturation.bind(this));
if (this.colorExpose.name === 'color_hs') {
this.monitors.push(new monitor_1.NestedCharacteristicMonitor(this.colorExpose.property, [
new monitor_1.PassthroughCharacteristicMonitor(this.colorComponentAExpose.property, service, hap_1.hap.Characteristic.Hue),
new monitor_1.PassthroughCharacteristicMonitor(this.colorComponentBExpose.property, service, hap_1.hap.Characteristic.Saturation),
]));
}
else if (this.colorExpose.name === 'color_xy') {
this.monitors.push(new ColorXyCharacteristicMonitor(service, this.colorExpose.property, this.colorComponentAExpose.property, this.colorComponentBExpose.property));
}
}
}
tryCreateColorTemperature(features, service) {
this.colorTempExpose = features.find((e) => e.name === 'color_temp' && (0, z2mModels_1.exposesHasNumericRangeProperty)(e) && (0, z2mModels_1.exposesCanBeSet)(e) && (0, z2mModels_1.exposesIsPublished)(e));
if (this.colorTempExpose !== undefined) {
const characteristic = (0, helpers_1.getOrAddCharacteristic)(service, hap_1.hap.Characteristic.ColorTemperature);
(0, helpers_1.copyExposesRangeToCharacteristic)(this.colorTempExpose, characteristic);
characteristic.on('set', this.handleSetColorTemperature.bind(this));
this.monitors.push(new monitor_1.PassthroughCharacteristicMonitor(this.colorTempExpose.property, service, hap_1.hap.Characteristic.ColorTemperature));
// Also supports colors?
if (this.colorTempExpose !== undefined && this.colorExpose !== undefined) {
// Add monitor to convert Color Temperature to Hue / Saturation
// based on the 'color_mode'
this.monitors.push(new ColorTemperatureToHueSatMonitor(service, this.colorTempExpose.property));
}
}
}
tryCreateBrightness(features, service) {
this.brightnessExpose = features.find((e) => e.name === 'brightness' && (0, z2mModels_1.exposesHasNumericRangeProperty)(e) && (0, z2mModels_1.exposesCanBeSet)(e) && (0, z2mModels_1.exposesIsPublished)(e));
if (this.brightnessExpose !== undefined) {
this.mainCharacteristics.push((0, helpers_1.getOrAddCharacteristic)(service, hap_1.hap.Characteristic.Brightness).on('set', this.handleSetBrightness.bind(this)));
this.monitors.push(new monitor_1.NumericCharacteristicMonitor(this.brightnessExpose.property, service, hap_1.hap.Characteristic.Brightness, this.brightnessExpose.value_min, this.brightnessExpose.value_max, undefined, undefined, true));
}
}
tryCreateAdaptiveLighting(service) {
// Need at least brightness and color temperature for AL to be possible
if (this.brightnessExpose === undefined || this.colorTempExpose === undefined) {
return;
}
if (!this.adaptiveLightingEnabled) {
// AL is disabled in config - check if there's a cached controller to remove
// The AL controller adds SupportedCharacteristicValueTransitionConfiguration to the service
if (service.testCharacteristic(hap_1.hap.Characteristic.SupportedCharacteristicValueTransitionConfiguration)) {
// AL was previously enabled - claim and remove the cached controller
const tempController = new hap_1.hap.AdaptiveLightingController(service);
this.accessory.configureController(tempController);
this.accessory.removeController(tempController);
this.accessory.log.debug(`Removed cached Adaptive Lighting controller for ${this.accessory.displayName}`);
}
return;
}
// AL is enabled - create and configure normally
this.adaptiveLighting = new hap_1.hap.AdaptiveLightingController(service).on('disable', this.resetAdaptiveLightingTemperature.bind(this));
this.accessory.configureController(this.adaptiveLighting);
}
resetAdaptiveLightingTemperature() {
this.lastAdaptiveLightingTemperature = undefined;
}
handleSetOn(value, callback) {
const data = {};
data[this.stateExpose.property] = value ? this.stateExpose.value_on : this.stateExpose.value_off;
this.accessory.queueDataForSetAction(data);
// Reset the cached color temperature for Adaptive Lighting when turning on
// This ensures the next AL update will be sent to the light
if (value === true) {
this.resetAdaptiveLightingTemperature();
}
callback(null);
}
handleSetBrightness(value, callback) {
if (typeof value !== 'number') {
this.accessory.log.warn(`Received non-numeric brightness value for ${this.accessory.displayName}: ${value}`);
callback(new Error('non-numeric brightness not supported'));
return;
}
if (this.brightnessExpose !== undefined) {
const data = {};
if (value <= 0) {
data[this.brightnessExpose.property] = this.brightnessExpose.value_min;
}
else if (value >= 100) {
data[this.brightnessExpose.property] = this.brightnessExpose.value_max;
}
else {
data[this.brightnessExpose.property] = Math.round(this.brightnessExpose.value_min + (value / 100) * (this.brightnessExpose.value_max - this.brightnessExpose.value_min));
}
this.accessory.queueDataForSetAction(data);
// Reset the cached color temperature for Adaptive Lighting after brightness change
// This ensures the next AL update will be sent to the light
this.resetAdaptiveLightingTemperature();
callback(null);
}
else {
callback(new Error('brightness not supported'));
}
}
handleSetColorTemperature(value, callback) {
if (this.colorTempExpose !== undefined && typeof value === 'number') {
const data = {};
if (value < this.colorTempExpose.value_min) {
value = this.colorTempExpose.value_min;
}
if (value > this.colorTempExpose.value_max) {
value = this.colorTempExpose.value_max;
}
data[this.colorTempExpose.property] = value;
if (this.handleAdaptiveLighting(value)) {
this.accessory.queueDataForSetAction(data);
}
callback(null);
}
else {
callback(new Error('color temperature not supported'));
}
}
handleSetHue(value, callback) {
this.cached_hue = value;
this.received_hue = true;
if (this.colorExpose?.name === 'color_hs' && this.colorComponentAExpose !== undefined) {
this.publishHueAndSaturation();
callback(null);
}
else if (this.colorExpose?.name === 'color_xy') {
this.convertAndPublishHueAndSaturationAsXY();
callback(null);
}
else {
callback(new Error('color not supported'));
}
}
handleSetSaturation(value, callback) {
this.cached_saturation = value;
this.received_saturation = true;
if (this.colorExpose?.name === 'color_hs' && this.colorComponentBExpose !== undefined) {
this.publishHueAndSaturation();
callback(null);
}
else if (this.colorExpose?.name === 'color_xy') {
this.convertAndPublishHueAndSaturationAsXY();
callback(null);
}
else {
callback(new Error('color not supported'));
}
}
publishHueAndSaturation() {
try {
if (this.received_hue && this.received_saturation) {
this.received_hue = false;
this.received_saturation = false;
if (this.adaptiveLighting?.isAdaptiveLightingActive()) {
// Hue/Saturation set from HomeKit, disable Adaptive Lighting
this.accessory.log.debug('adaptive_lighting: disable due to hue/sat');
this.adaptiveLighting.disableAdaptiveLighting();
}
if (this.colorExpose?.name === 'color_hs' &&
this.colorExpose?.property !== undefined &&
this.colorComponentAExpose !== undefined &&
this.colorComponentBExpose !== undefined) {
const data = {};
data[this.colorExpose.property] = {};
data[this.colorExpose.property][this.colorComponentAExpose.property] = this.cached_hue;
data[this.colorExpose.property][this.colorComponentBExpose.property] = this.cached_saturation;
this.accessory.queueDataForSetAction(data);
}
}
}
catch (error) {
this.accessory.log.error(`Failed to handle hue/saturation update for ${this.accessory.displayName}: ${error}`);
}
}
convertAndPublishHueAndSaturationAsXY() {
try {
if (this.received_hue && this.received_saturation) {
this.received_hue = false;
this.received_saturation = false;
if (this.adaptiveLighting?.isAdaptiveLightingActive()) {
// Hue/Saturation set from HomeKit, disable Adaptive Lighting
this.accessory.log.debug('adaptive_lighting: disable due to hue/sat');
this.adaptiveLighting.disableAdaptiveLighting();
}
if (this.colorExpose?.name === 'color_xy' &&
this.colorExpose?.property !== undefined &&
this.colorComponentAExpose !== undefined &&
this.colorComponentBExpose !== undefined) {
const data = {};
const xy = (0, colorhelper_1.convertHueSatToXy)(this.cached_hue, this.cached_saturation);
data[this.colorExpose.property] = {};
data[this.colorExpose.property][this.colorComponentAExpose.property] = xy[0];
data[this.colorExpose.property][this.colorComponentBExpose.property] = xy[1];
this.accessory.queueDataForSetAction(data);
}
}
}
catch (error) {
this.accessory.log.error(`Failed to handle hue/saturation update for ${this.accessory.displayName}: ${error}`);
}
}
static generateIdentifier(endpoint) {
let identifier = hap_1.hap.Service.Lightbulb.UUID;
if (endpoint !== undefined) {
identifier += '_' + endpoint.trim();
}
return identifier;
}
handleAdaptiveLighting(value) {
// Adaptive Lighting active?
if (this.colorTempExpose !== undefined && this.adaptiveLighting !== undefined && this.adaptiveLighting.isAdaptiveLightingActive()) {
const lightIsOn = this.service.getCharacteristic(hap_1.hap.Characteristic.On).value;
if (this.adaptiveLightingConfig.only_when_on && lightIsOn === false) {
this.accessory.log.debug(`adaptive_lighting: ${this.accessory.displayName}: skipped, light is off`);
return false;
}
if (this.lastAdaptiveLightingTemperature === undefined) {
this.lastAdaptiveLightingTemperature = value;
}
else {
const change = Math.abs(this.lastAdaptiveLightingTemperature - value);
const minDelta = this.adaptiveLightingConfig.min_delta ?? 1;
if (change < minDelta) {
this.accessory.log.debug(`adaptive_lighting: ${this.accessory.displayName}: skipped ${this.colorTempExpose.property} (new: ${value}; ` +
`old: ${this.lastAdaptiveLightingTemperature})`);
return false;
}
if (lightIsOn && this.adaptiveLightingConfig.transition !== undefined && this.adaptiveLightingConfig.transition > 0) {
this.accessory.queueDataForSetAction({ transition: this.adaptiveLightingConfig.transition });
}
this.accessory.log.debug(`adaptive_lighting: ${this.accessory.displayName}: ${this.colorTempExpose.property} ${value}`);
this.lastAdaptiveLightingTemperature = value;
}
}
else {
this.resetAdaptiveLightingTemperature();
}
return true;
}
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: utility method for future use
updateHueAndSaturationBasedOnColorTemperature(value) {
if (this.colorHueCharacteristic !== undefined && this.colorSaturationCharacteristic !== undefined) {
const color = hap_1.hap.ColorUtils.colorTemperatureToHueAndSaturation(value, true);
this.colorHueCharacteristic.updateValue(color.hue);
this.colorSaturationCharacteristic.updateValue(color.saturation);
}
}
}
class ColorTemperatureToHueSatMonitor {
service;
key_temp;
constructor(service, key_temp) {
this.service = service;
this.key_temp = key_temp;
}
callback(state) {
if (this.key_temp in state &&
LightHandler.KEY_COLOR_MODE in state &&
state[LightHandler.KEY_COLOR_MODE] === LightHandler.COLOR_MODE_TEMPERATURE) {
const temperature = state[this.key_temp];
const hueSat = (0, colorhelper_1.convertMiredColorTemperatureToHueSat)(temperature);
this.service.updateCharacteristic(hap_1.hap.Characteristic.Hue, hueSat[0]);
this.service.updateCharacteristic(hap_1.hap.Characteristic.Saturation, hueSat[1]);
}
}
}
class ColorXyCharacteristicMonitor {
service;
key;
key_x;
key_y;
constructor(service, key, key_x, key_y) {
this.service = service;
this.key = key;
this.key_x = key_x;
this.key_y = key_y;
}
callback(state) {
if (this.key in state) {
const nested_state = state[this.key];
if (this.key_x in nested_state && this.key_y in nested_state) {
const value_x = nested_state[this.key_x];
const value_y = nested_state[this.key_y];
const hueSat = (0, colorhelper_1.convertXyToHueSat)(value_x, value_y);
this.service.updateCharacteristic(hap_1.hap.Characteristic.Hue, hueSat[0]);
this.service.updateCharacteristic(hap_1.hap.Characteristic.Saturation, hueSat[1]);
}
}
}
}
//# sourceMappingURL=light.js.map