// 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>; 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)); } } } @characteristicSetter(Characteristics.On) 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); } } @characteristicSetter(Characteristics.Brightness) @throttle(100, 100, 500) 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); } @characteristicSetter(Characteristics.TargetPosition) @throttle(500, 200, 1400) 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]); } } @characteristicSetter(Characteristics.HoldPosition) @throttle(1000, 200, 1200) async setHoldPosition(value, context, characteristic, service, IDs) { if (value) { await this.command('stop', [0], service, IDs); } } @characteristicSetter(Characteristics.TargetHorizontalTiltAngle) 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); } @characteristicSetter(Characteristics.LockTargetState) 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); } } @characteristicSetter(Characteristics.TargetDoorState) 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); } @characteristicSetter(Characteristics.TargetHeatingCoolingState) 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; } } @characteristicSetter(Characteristics.TargetTemperature) 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; } } @characteristicSetter(Characteristics.Hue) async setHue(value, context, characteristic, service, IDs) { this.updateHomeCenterColorFromHomeKit(value, null, service, IDs); } @characteristicSetter(Characteristics.Saturation) async setSaturation(value, context, characteristic, service, IDs) { this.updateHomeCenterColorFromHomeKit(null, value, service, IDs); } @characteristicSetter(Characteristics.SecuritySystemTargetState) 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); } @characteristicSetter(Characteristics.Active) 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]}`); } } }