homebridge-fibaro-home-center
Version:
Homebridge plugin for Fibaro Home Center (2, 2 Lite, 3, 3 Lite, Yubii Home).
441 lines (377 loc) • 15.9 kB
text/typescript
// setFunctions.ts
import 'reflect-metadata';
import { Utils } from './utils';
import { Characteristics } from './constants';
// Decorator function for mapping setCharacteristicValue to the Characteristic
function characteristicSetter(...characteristics: unknown[]) {
return function (target, propertyKey: string, descriptor: PropertyDescriptor) {
const existingMethods = Reflect.getMetadata('characteristicMethods', target) || [];
existingMethods.push([propertyKey, characteristics]);
Reflect.defineMetadata('characteristicMethods', existingMethods, target);
return descriptor;
};
}
// Throttle decorator
function throttle(
timeWindow: number,
inactivityThreshold: number,
skipInterval: number = 0,
) {
return function (
target,
propertyKey: string,
descriptor: PropertyDescriptor,
) {
const originalMethod = descriptor.value;
// Map to store per-device state using device identifiers
const deviceStateMap = new Map();
descriptor.value = function (...args) {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const device = this;
// Obtain the device identifier from IDs[0]
const IDs = args[4]; // IDs is the 5th argument
const deviceId = IDs && IDs[0];
if (!deviceId) {
throw new Error(
'Device ID could not be determined. Please provide a valid IDs array.',
);
}
let state = deviceStateMap.get(deviceId);
if (!state) {
state = {
lastInvocationTime: 0,
lastCallTime: 0,
lastArgs: null,
skipIntervalTimerId: null,
skipIntervalEndTime: 0,
inactivityTimerId: null,
executionScheduled: false,
isBurst: false,
};
deviceStateMap.set(deviceId, state);
}
const now = Date.now();
// Update last call time and args
state.lastCallTime = now;
state.lastArgs = args;
// If we're not in a burst, apply skipInterval
if (!state.isBurst) {
state.isBurst = true;
if (skipInterval > 0) {
// Start skipInterval timer without executing immediately
state.skipIntervalEndTime = now + skipInterval;
state.skipIntervalTimerId = setTimeout(() => {
state.skipIntervalTimerId = null;
scheduleExecution();
}, skipInterval);
} else {
// skipInterval is zero
scheduleExecution();
}
} else {
// We're in a burst, schedule execution as per timeWindow and inactivityThreshold
scheduleExecution();
}
// Reset the inactivity timer
if (state.inactivityTimerId) {
clearTimeout(state.inactivityTimerId);
}
state.inactivityTimerId = setTimeout(() => {
// Inactivity threshold passed, burst has ended
state.isBurst = false;
// Schedule the last execution if needed
scheduleExecution();
}, inactivityThreshold);
function scheduleExecution() {
const now = Date.now();
// Calculate time since last invocation for timeWindow
const timeSinceLastInvocation = now - state.lastInvocationTime;
const timeWindowRemaining = timeWindow - timeSinceLastInvocation;
const timeWindowDelay =
timeWindowRemaining > 0 ? timeWindowRemaining : 0;
let totalDelay = timeWindowDelay;
// If skipInterval is active, adjust totalDelay to ensure skipInterval is respected
if (state.skipIntervalTimerId) {
const skipIntervalRemaining = state.skipIntervalEndTime - now;
totalDelay = Math.max(totalDelay, skipIntervalRemaining);
}
if (state.executionScheduled) {
// Execution already scheduled, no need to schedule again
return;
}
state.executionScheduled = true;
setTimeout(() => {
executeMethod();
}, totalDelay);
}
function executeMethod() {
state.lastInvocationTime = Date.now();
state.executionScheduled = false;
originalMethod.apply(device, state.lastArgs);
// Clear timers
if (state.inactivityTimerId) {
clearTimeout(state.inactivityTimerId);
state.inactivityTimerId = null;
}
}
};
};
}
export class SetFunctions {
setFunctionsMapping: Map<unknown, (...args: unknown[]) => unknown>;
modeMap;
constructor(private platform) {
this.setFunctionsMapping = new Map();
const { TargetHeatingCoolingState } = this.platform.Characteristic;
this.modeMap = {
[TargetHeatingCoolingState.OFF]: 'Off',
[TargetHeatingCoolingState.HEAT]: 'Heat',
[TargetHeatingCoolingState.COOL]: 'Cool',
[TargetHeatingCoolingState.AUTO]: 'Auto',
};
this.initializeFunctionsMapping();
}
private initializeFunctionsMapping() {
const prototype = Object.getPrototypeOf(this);
const characteristicMethods = Reflect.getMetadata('characteristicMethods', prototype) || [];
for (const [methodName, characteristics] of characteristicMethods) {
for (const characteristic of characteristics) {
this.setFunctionsMapping.set(this.platform.Characteristic[characteristic], this[methodName].bind(this));
}
}
}
async setOn(value, context, characteristic, service, IDs) {
const setValue = (delay = 100) => {
setTimeout(() => {
characteristic.setValue(0, undefined, 'fromSetValue');
}, delay);
};
if (service.isVirtual && !service.isGlobalVariableSwitch && !service.isGlobalVariableDimmer) {
// It's a virtual device so the command is pressButton and not turnOn or Off
await this.command('pressButton', [IDs[1]], service, IDs);
// In order to behave like a push button reset the status to off
setValue();
} else if (service.isScene) {
// It's a scene so the command is execute scene
await this.scene(IDs[0]);
// In order to behave like a push button reset the status to off
setValue();
} else if (service.isGlobalVariableSwitch) {
await this.setGlobalVariable(IDs[1], value ? 'true' : 'false');
} else if (service.isGlobalVariableDimmer) {
const currentDimmerValue = (await this.getGlobalVariable(IDs[1])).body.value;
if (currentDimmerValue !== '0' && value) {
return;
}
await this.setGlobalVariable(IDs[1], value ? '100' : '0');
} else {
await this.command(value ? 'turnOn' : 'turnOff', null, service, IDs);
}
}
async setBrightness(value, context, characteristic, service, IDs) {
// Handle global variable dimmer separately
if (service.isGlobalVariableDimmer) {
await this.setGlobalVariable(IDs[1], value.toString());
return;
}
await this.command('setValue', [value], service, IDs);
}
async setTargetPosition(value, context, characteristic, service, IDs) {
if (service.isOpenCloseOnly) {
// For open/close only devices, use specific commands based on the target position
const action = value === 0 ? 'close' : value >= 99 ? 'open' : null;
if (action) {
await this.command(action, [0], service, IDs);
}
return;
}
const result = await this.command('setValue', [value], service, IDs);
if (result && result.body && !result.body.result) {
// The command was discarded because the device is not ready to accept commands
// set the characteristic value to the current value of the device
characteristic.updateValue(service.getCharacteristic(this.platform.Characteristic.CurrentPosition).value, undefined, 'fromSetValue');
this.platform.log.warn('setValue discarded for device', IDs[0]);
}
}
async setHoldPosition(value, context, characteristic, service, IDs) {
if (value) {
await this.command('stop', [0], service, IDs);
}
}
async setTargetTiltAngle(angle, context, characteristic, service, IDs) {
const value2 = Utils.scale(angle, characteristic.props.minValue, characteristic.props.maxValue, 0, 100);
await this.command('setValue2', [value2], service, IDs);
}
async setLockTargetState(value, context, characteristic, service, IDs) {
const Characteristic = this.platform.Characteristic;
const isUnsecured = value === Characteristic.LockTargetState.UNSECURED;
if (service.isLockSwitch) {
// Handle lock switch
const action = isUnsecured ? 'turnOn' : 'turnOff';
await this.command(action, null, service, IDs);
service.getCharacteristic(Characteristic.LockCurrentState).updateValue(value, undefined, 'fromSetValue');
return;
}
// Handle regular lock
const action = isUnsecured ? 'unsecure' : 'secure';
await this.command(action, [0], service, IDs);
// Update lock current state after a delay
setTimeout(() => {
service.getCharacteristic(Characteristic.LockCurrentState).updateValue(value, undefined, 'fromSetValue');
}, 1000);
// Check if the action is correctly executed after a specified timeout
if (this.platform.config.doorlocktimeout !== '0') {
const timeout = parseInt(this.platform.config.doorlocktimeout) * 1000;
setTimeout(() => this.checkLockCurrentState(IDs, value), timeout);
}
}
async setTargetDoorState(value, context, characteristic, service, IDs) {
const action = value === this.platform.Characteristic.TargetDoorState.CLOSED ? 'close' : 'open';
await this.command(action, [0], service, IDs);
setTimeout(() => {
characteristic.setValue(value, undefined, 'fromSetValue');
service.getCharacteristic(this.platform.Characteristic.CurrentDoorState)
.setValue(value, undefined, 'fromSetValue');
}, 100);
}
async setTargetHeatingCoolingState(value, context, characteristic, service, IDs) {
switch (true) {
case service.isRadiatorThermostaticValve:
case service.isHeatingZone:
return; // Early return as they don't support mode changes
case service.isClimateZone: {
const mode = this.modeMap[value];
if (!mode) {
return;
}
const currentTemperature = service.getCharacteristic(this.platform.Characteristic.CurrentTemperature).value;
const timestamp = Math.trunc(Date.now() / 1000) + parseInt(this.platform.config.thermostattimeout);
await this.platform.fibaroClient.setClimateZoneHandTemperature(IDs[0], mode, currentTemperature, timestamp);
break;
}
default:
break;
}
}
async setTargetTemperature(value, context, characteristic, service, IDs) {
const timestamp = Math.trunc(Date.now() / 1000) + parseInt(this.platform.config.thermostattimeout);
switch (true) {
case service.isClimateZone: {
const currentState = service.getCharacteristic(this.platform.Characteristic.CurrentHeatingCoolingState).value;
const mode = this.modeMap[currentState];
if (mode) {
await this.platform.fibaroClient.setClimateZoneHandTemperature(IDs[0], mode, value, timestamp);
}
break;
}
case service.isHeatingZone:
await this.platform.fibaroClient.setHeatingZoneHandTemperature(IDs[0], value, timestamp);
break;
case service.isRadiatorThermostaticValve:
await this.command('setHeatingThermostatSetpoint', [value], service, IDs);
break;
default:
break;
}
}
async setHue(value, context, characteristic, service, IDs) {
this.updateHomeCenterColorFromHomeKit(value, null, service, IDs);
}
async setSaturation(value, context, characteristic, service, IDs) {
this.updateHomeCenterColorFromHomeKit(null, value, service, IDs);
}
async setSecuritySystemTargetState(value) {
const { Characteristic } = this.platform;
const sceneMap = new Map([
[Characteristic.SecuritySystemTargetState.AWAY_ARM, this.platform.scenes.SetAwayArmed],
[Characteristic.SecuritySystemTargetState.DISARM, this.platform.scenes.SetDisarmed],
[Characteristic.SecuritySystemTargetState.NIGHT_ARM, this.platform.scenes.SetNightArmed],
[Characteristic.SecuritySystemTargetState.STAY_ARM, this.platform.scenes.SetStayArmed],
]);
const sceneID = sceneMap.get(value);
if (sceneID === undefined) {
return;
}
if (value === Characteristic.SecuritySystemTargetState.DISARM) {
value = Characteristic.SecuritySystemCurrentState.DISARMED;
}
await this.scene(sceneID);
}
async setActive(value, context, characteristic, service, IDs) {
const action = (value === this.platform.Characteristic.Active.ACTIVE) ? 'turnOn' : 'turnOff';
await this.command(action, null, service, IDs);
}
async updateHomeCenterColorFromHomeKit(h, s, service, IDs) {
if (h !== null) {
service.h = h;
}
if (s !== null) {
service.s = s;
}
if (service.h !== undefined && service.s !== undefined) {
const v = service.characteristics[2].value;
const rgbw = Utils.HSVtoRGB(service.h, service.s, v);
await this.command('setColor', [rgbw.r, rgbw.g, rgbw.b, rgbw.w], service, IDs);
await this.command('setValue', [v], service, IDs);
delete service.h;
delete service.s;
}
}
async command(c, value, service, IDs) {
const result = await this.platform.fibaroClient.executeDeviceAction(IDs[0], c, value);
if (this.platform.config.logsLevel >= 1) {
const nc = c.replace(/turnOn|turnOff|setValue|open|close|setColor|setHeatingThermostatSetpoint/g, match => {
const replacements = {
turnOn: 'On',
turnOff: 'Off',
setValue: '',
open: 'Open',
close: 'Close',
setColor: 'Color',
setHeatingThermostatSetpoint: 'Setpoint',
};
return replacements[match] || match;
});
const logMessage = `${service.displayName} [${IDs[0]}]: set ${nc}${
value !== null && nc !== 'Open' && nc !== 'Close' && nc !== 'Color' && nc !== 'Setpoint' ? ` ${value}%` :
(value !== null && (nc === 'Color' || nc === 'Setpoint') ? ` ${value}` : '')
}`;
this.platform.log(logMessage);
}
return result;
}
async scene(sceneID) {
await this.platform.fibaroClient.executeScene(sceneID, this.platform.isOldApi());
}
async setGlobalVariable(variableID, value) {
await this.platform.fibaroClient.setGlobalVariable(variableID, value);
}
async getGlobalVariable(variableID) {
const value = await this.platform.fibaroClient.getGlobalVariable(variableID);
return value;
}
async checkLockCurrentState(IDs, value) {
const properties = (await this.platform.fibaroClient.getDeviceProperties(IDs[0])).body.properties;
const currentValue = (properties.value === true) ?
this.platform.Characteristic.LockCurrentState.SECURED : this.platform.Characteristic.LockCurrentState.UNSECURED;
if (currentValue !== value) {
this.platform.log.error('There was a problem setting value to Lock: ', `${IDs[0]}`);
}
}
}