itemutils/dimmer.js

/**
 * Copyright (c) 2022 Florian Hotze
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0
 *
 * SPDX-License-Identifier: EPL-2.0
 */

const { cache, items, log } = require('openhab');
const logger = log('org.openhab.automation.js.openhab-tools.itemutils.dimmer');

/**
 * Dims an Item step-by-step to a target state.
 *
 * Only works for Items with support for float states.
 * The dimmer uses the shared cache to ensure that there are not multiple timers for the same Item active at the same time.
 *
 * @example
 * // Dim the Bedroom_Light to 50% in 750 seconds (1% each 15 seconds).
 * itemutils.dimItem('Bedroom_Light', 50.0, 1, 15 * 1000);
 *
 * @memberof itemutils
 * @param {string} itemName name of the Item to control
 * @param {number} targetState float number to dim to
 * @param {number} step dimming step size
 * @param {number} time time in milliseconds between each step
 * @param {boolean} [ignoreExternalChange=false] whether to break dimmer if Item receives a large external change
 * @param {boolean} [overwrite=false] whether to cancel an existing dimmer and create a new one
 * @throws {TypeError} when a parameter has wrong type
 *
 */
function dimItem (itemName, targetState, step, time, ignoreExternalChange = false, overwrite = false) {
  // Get Item and check parameters.
  const item = items.getItem(itemName);
  // @ts-ignore
  if (typeof targetState !== 'number') throw TypeError('targetState must be a number!');
  if (typeof step !== 'number') throw TypeError('step must be a number!');
  if (typeof time !== 'number') throw TypeError('time must be a number!');

  const CACHE_KEY = 'hotzware_openhab-js-tools_dimmer-for-' + itemName;

  // If targetState already met, do not create a dimmer.
  if (items.getItem(itemName).numericState === targetState) {
    logger.debug(`${itemName} already has targetState ${targetState}, skipping.`);
    return;
  }

  // If targetState not met, create dimmer.
  // If dimmer for itemName already exists, do not create new one.
  const intervalIdFromCache = cache.shared.get(CACHE_KEY);
  if (intervalIdFromCache !== null) {
    if (overwrite) {
      clearInterval(intervalIdFromCache);
      cache.shared.put(CACHE_KEY, null);
      logger.info(`Dimmer for Item ${itemName} already exists, overwriting.`);
    } else {
      logger.info(`Dimmer for Item ${itemName} already exists, skipping.`);
      return;
    }
  }

  // Initialize and create dimmer.
  logger.info(`Dimming Item ${itemName} to ${targetState} ...`);
  let calculatedState = items.getItem(itemName).numericState;
  const intervalId = setInterval(() => {
    /**
     * Cancels the dimmer and removes the intervalId from the cache.
     * @param {string} msg message to log
     */
    function breakDimmer (msg) {
      logger.info(`Dimmer for Item ${itemName} ${msg}`);
      clearInterval(intervalId);
      cache.shared.put(CACHE_KEY, null);
    }
    // Cancel when targetState is reached.
    // Workaround for an issue where the target state is never exactly met because the step size is too large and therefore the dimmer never ends.
    if (Math.abs(calculatedState - targetState) < step) {
      item.sendCommand(targetState.toString()); // Ensure the target state is met
      logger.trace(`Dimmer for ${itemName}: Sending command ${targetState}.`);
      breakDimmer('reached target state.');
      return;
    }
    // Item receives large external change or state update on Item is really slow.
    const realState = items.getItem(itemName).numericState;
    if (Math.abs(realState - calculatedState) > 2) {
      logger.debug(`Dimmer ${itemName}: Difference between real state ${realState} and calculated state ${calculatedState} is large. External item change happened or state update is slow.`);
      if (!ignoreExternalChange) {
        breakDimmer('cancelled due to external change.');
        return;
      }
    }
    // Dim to target state.
    calculatedState = calculatedState > targetState ? calculatedState - step : (calculatedState < targetState ? calculatedState + step : calculatedState);
    logger.trace(`Dimmer for ${itemName}: Sending command ${calculatedState}.`);
    item.sendCommand(calculatedState.toString());
  }, time);
  cache.shared.put(CACHE_KEY, intervalId);
}

module.exports = {
  dimItem
};