items/items.js

/**
 * Items namespace.
 * This namespace handles querying and updating openHAB Items.
 * @namespace items
 */

const osgi = require('../osgi');
const utils = require('../utils');
const log = require('../log')('items');
const { _toOpenhabPrimitiveType, _isQuantity, _isItem } = require('../helpers');
const { getQuantity, QuantityError } = require('../quantity');

const { OnOffType, PercentType, UnDefType, events, itemRegistry } = require('@runtime');

const metadata = require('./metadata/metadata');
const ItemPersistence = require('./item-persistence');
const ItemSemantics = require('./item-semantics');
const TimeSeries = require('./time-series');

const itemBuilderFactory = osgi.getService('org.openhab.core.items.ItemBuilderFactory');

// typedefs need to be global for TypeScript to fully work
/**
 * @typedef {object} ItemConfig configuration describing an Item
 * @property {string} type the type of the Item
 * @property {string} name Item name for the Item to create
 * @property {string} [label] the label for the Item
 * @property {string} [category] the category (icon) for the Item
 * @property {string[]} [groups] an array of groups the Item is a member of
 * @property {string[]} [tags] an array of tags for the Item
 * @property {string|Object} [channels] for single channel link a string or for multiple an object { channeluid: configuration }; configuration is an object
 * @property {*} [metadata] either object `{ namespace: value }` or `{ namespace: `{@link ItemMetadata}` }`
 * @property {string} [giBaseType] the group Item base type for the Item
 * @property {HostGroupFunction} [groupFunction] the group function used by the Item
 */
/**
 * @typedef {import('../items/metadata/metadata').ItemMetadata} ItemMetadata
 * @private
 */
/**
 * @typedef {import('@js-joda/core').ZonedDateTime} time.ZonedDateTime
 * @private
 */
/**
 * @typedef {import('../quantity').Quantity} Quantity
 * @private
 */

/**
 * Tag value to be attached to all dynamically created Items.
 *
 * @memberof items
 */
const DYNAMIC_ITEM_TAG = '_DYNAMIC_';

/**
 * Class representing an openHAB Item
 *
 * @memberof items
 */
class Item {
  /**
   * Create an Item, wrapping a native Java openHAB Item. Don't use this constructor, instead call {@link getItem}.
   * @param {HostItem} rawItem Java Item from Host
   * @hideconstructor
   */
  constructor (rawItem) {
    if (typeof rawItem === 'undefined') {
      throw Error('Supplied Item is undefined');
    }
    /**
     * raw Java Item
     * @type {HostItem}
     */
    this.rawItem = rawItem;

    /**
     * Access historical states for this Item {@link items.ItemPersistence}
     * @type {ItemPersistence}
     */
    this.persistence = new ItemPersistence(rawItem);

    /**
     * Access Semantic information of this Item {@link items.ItemSemantics}
     * @type {ItemSemantics}
     */
    this.semantics = new ItemSemantics(rawItem, getItem);
  }

  /**
   * Type of Item: the Simple (without package) name of the Java Item type, such as 'Switch'.
   * @type {string}
   */
  get type () {
    return this.rawItem.getType().toString();
  }

  /**
   * Name of Item
   * @type {string}
   */
  get name () {
    return this.rawItem.getName();
  }

  /**
   * Label attached to Item
   * @type {string}
   */
  get label () {
    return this.rawItem.getLabel();
  }

  /**
   * String representation of the Item state.
   * @type {string}
   */
  get state () {
    return this.rawState.toString();
  }

  /**
   * Numeric representation of Item state, or `null` if state is not numeric
   * @type {number|null}
   */
  get numericState () {
    let state = this.rawState.toString();
    if (this.type === 'Color') state = this.rawItem.getStateAs(PercentType).toString();
    const numericState = parseFloat(state);
    return isNaN(numericState) ? null : numericState;
  }

  /**
   * Item state as {@link Quantity} or `null` if state is not Quantity-compatible or Quantity would be unit-less (without unit)
   * @type {Quantity|null}
   */
  get quantityState () {
    try {
      const qty = getQuantity(this.rawState.toString());
      return (qty !== null && qty.symbol !== null) ? qty : null;
    } catch (e) {
      if (e instanceof QuantityError) {
        return null;
      } else {
        throw Error('Failed to create "quantityState": ' + e);
      }
    }
  }

  /**
   * Raw state of Item, as a Java {@link https://www.openhab.org/javadoc/latest/org/openhab/core/types/state State object}
   * @type {HostState}
   */
  get rawState () {
    return this.rawItem.getState();
  }

  /**
   * Members / children / direct descendents of the current group Item (as returned by 'getMembers()'). Must be a group Item.
   * @type {Item[]}
   */
  get members () {
    return utils.javaSetToJsArray(this.rawItem.getMembers()).map(raw => new Item(raw));
  }

  /**
   * All descendents of the current group Item (as returned by 'getAllMembers()'). Must be a group Item.
   * @type {Item[]}
   */
  get descendents () {
    return utils.javaSetToJsArray(this.rawItem.getAllMembers()).map(raw => new Item(raw));
  }

  /**
   * Whether this Item is uninitialized (`true if it has not been initialized`).
   * @type {boolean}
   */
  get isUninitialized () {
    if (this.rawItem.getState() instanceof UnDefType ||
        this.rawItem.getState().toString() === 'Undefined' ||
        this.rawItem.getState().toString() === 'Uninitialized'
    ) {
      return true;
    } else {
      return false;
    }
  }

  /**
   * Gets metadata of a single namespace or of all namespaces from this Item.
   *
   * @example
   * // Get metadata of ALL namespaces
   * var meta = Item.getMetadata();
   * var namespaces = Object.keys(meta); // Get metadata namespaces
   * // Get metadata of a single namespace
   * meta = Item.getMetadata('expire');
   *
   * @see items.metadata.getMetadata
   * @param {string} [namespace] name of the metadata: if provided, only metadata of this namespace is returned, else all metadata is returned
   * @returns {{ namespace: ItemMetadata }|ItemMetadata|null} all metadata as an object with the namespaces as properties OR metadata of a single namespace or `null` if that namespace doesn't exist; the metadata itself is of type {@link items.metadata.ItemMetadata}
   */
  getMetadata (namespace) {
    return metadata.getMetadata(this.name, namespace);
  }

  /**
   * Updates or adds metadata of a single namespace to this Item.
   *
   * @see items.metadata.replaceMetadata
   * @param {string} namespace name of the metadata
   * @param {string} value value for this metadata
   * @param {object} [configuration] optional metadata configuration
   * @returns {{configuration: *, value: string}|null} old {@link items.metadata.ItemMetadata} or `null` if the Item has no metadata with the given name
   */
  replaceMetadata (namespace, value, configuration) {
    return metadata.replaceMetadata(this.name, namespace, value, configuration);
  }

  /**
   * Removes metadata of a single namespace or of all namespaces from a given Item.
   *
   * @see items.metadata.removeMetadata
   * @param {string} [namespace] name of the metadata: if provided, only metadata of this namespace is removed, else all metadata is removed
   * @returns {ItemMetadata|null} removed {@link items.metadata.ItemMetadata} OR `null` if the Item has no metadata under the given namespace or all metadata was removed
   */
  removeMetadata (namespace) {
    return metadata.removeMetadata(this.name, namespace);
  }

  /**
   * Sends a command to the Item.
   *
   * @param {string|number|time.ZonedDateTime|Quantity|HostState} value the value of the command to send, such as 'ON'
   * @see sendCommandIfDifferent
   * @see postUpdate
   */
  sendCommand (value) {
    events.sendCommand(this.rawItem, _toOpenhabPrimitiveType(value));
  }

  /**
   * Sends a command to the Item, but only if the current state is not what is being sent.
   *
   * @param {string|number|time.ZonedDateTime|Quantity|HostState} value the value of the command to send, such as 'ON'
   * @returns {boolean} true if the command was sent, false otherwise
   * @see sendCommand
   */
  sendCommandIfDifferent (value) {
    // value and current state both are Quantity and have equal value
    if (_isQuantity(value) && this.quantityState !== null) {
      if (value.equal(this.quantityState)) {
        return false;
      }
    }

    // value and current state are both numeric and have equal value
    if (typeof value === 'number' && this.numericState !== null) {
      if (value === this.numericState) {
        return false;
      }
    }

    // stringified value and string state are equal
    value = _toOpenhabPrimitiveType(value);
    if (value.toString() === this.state) {
      return false;
    }

    // else send the command
    this.sendCommand(value);
    return true;
  }

  /**
   * Increase the value of this Item to the given value by sending a command, but only if the current state is less than that value.
   *
   * @param {number|Quantity|HostState} value the value of the command to send, such as 'ON'
   * @return {boolean} true if the command was sent, false otherwise
   */
  sendIncreaseCommand (value) {
    // value and current state both are Quantity and value is less than or equal current state
    if (_isQuantity(value) && this.quantityState !== null) {
      if (value.lessThanOrEqual(this.quantityState)) {
        log.debug('sendIncreaseCommand: Ignoring command {} for Item {} with state {}', value, this.name, this.state);
        return false;
      }
    }

    // value and current state are both numeric and value is less than or equal current state
    if (typeof value === 'number' && this.numericState !== null) {
      if (value <= this.numericState) {
        log.debug('sendIncreaseCommand: Ignoring command {} for Item {} with state {}', value, this.name, this.state);
        return false;
      }
    }

    // else send the command
    log.debug('sendIncreaseCommand: Sending command {} to Item {} with state {}', value, this.name, this.state);
    this.sendCommand(value);
    return true;
  }

  /**
   * Decreases the value of this Item to the given value by sending a command, but only if the current state is greater than that value.
   *
   * @param {number|Quantity|HostState} value the value of the command to send, such as 'ON'
   * @return {boolean} true if the command was sent, false otherwise
   */
  sendDecreaseCommand (value) {
    // value and current state both are Quantity and value is greater than or equal current state
    if (_isQuantity(value) && this.quantityState !== null) {
      if (value.greaterThanOrEqual(this.quantityState)) {
        log.debug('sendDecreaseCommand: Ignoring command {} for Item {} with state {}', value, this.name, this.state);
        return false;
      }
    }

    // value and current state are both numeric and value is greater than or equal current state
    if (typeof value === 'number' && this.numericState !== null) {
      if (value >= this.numericState) {
        log.debug('sendDecreaseCommand: Ignoring command {} for Item {} with state {}', value, this.name, this.state);
        return false;
      }
    }

    // else send the command
    log.debug('sendDecreaseCommand: Sending command {} to Item {} with state {}', value, this.name, this.state);
    this.sendCommand(value);
    return true;
  }

  /**
   * Calculates the toggled state of this Item.
   * For Items like Color and Dimmer, getStateAs(OnOffType) is used and the toggle calculated of that.
   *
   * @ignore
   * @returns the toggled state (e.g. 'OFF' if the Item is 'ON')
   * @throws error if the Item is uninitialized or is a type that doesn't make sense to toggle
   */
  getToggleState () {
    if (this.isUninitialized) {
      throw Error('Cannot toggle uninitialized Items');
    }
    switch (this.type) {
      case 'Player' :
        return this.state === 'PAUSE' ? 'PLAY' : 'PAUSE';
      case 'Contact' :
        return this.state === 'OPEN' ? 'CLOSED' : 'OPEN';
      default: {
        const oldState = this.rawItem.getStateAs(OnOffType);
        if (oldState) {
          return oldState.toString() === 'ON' ? 'OFF' : 'ON';
        } else {
          throw Error('Toggle not supported for Items of type ' + this.type);
        }
      }
    }
  }

  /**
   * Sends a command to flip the Item's state (e.g. if it is 'ON' an 'OFF' command is sent).
   * @throws error if the Item is uninitialized or a type that cannot be toggled or commanded
   */
  sendToggleCommand () {
    if (this.type === 'Contact') {
      throw Error('Cannot command Contact Items');
    }
    this.sendCommand(this.getToggleState());
  }

  /**
   * Posts an update to flip the Item's state (e.g. if it is 'ON' an 'OFF'
   * update is posted).
   * @throws error if the Item is uninitialized or a type that cannot be toggled
   */
  postToggleUpdate () {
    this.postUpdate(this.getToggleState());
  }

  /**
   * Posts an update to the Item.
   *
   * @param {string|number|time.ZonedDateTime|Quantity|HostState} value the value of the command to send, such as 'ON'
   * @see postToggleUpdate
   * @see sendCommand
   */
  postUpdate (value) {
    events.postUpdate(this.rawItem, _toOpenhabPrimitiveType(value));
  }

  /**
   * Gets the names of the groups this Item is member of.
   * @returns {string[]}
   */
  get groupNames () {
    return utils.javaListToJsArray(this.rawItem.getGroupNames());
  }

  /**
   * Adds groups to this Item
   * @param {...string|...Item} groupNamesOrItems one or more names of the groups (or the group Items themselves)
   */
  addGroups (...groupNamesOrItems) {
    const groupNames = groupNamesOrItems.map((x) => (typeof x === 'string') ? x : x.name);
    this.rawItem.addGroupNames(groupNames);
    itemRegistry.update(this.rawItem);
  }

  /**
   * Removes groups from this Item
   * @param {...string|...Item} groupNamesOrItems one or more names of the groups (or the group Items themselves)
   */
  removeGroups (...groupNamesOrItems) {
    const groupNames = groupNamesOrItems.map((x) => (typeof x === 'string') ? x : x.name);
    for (const groupName of groupNames) {
      this.rawItem.removeGroupName(groupName);
    }
    itemRegistry.update(this.rawItem);
  }

  /**
   * Gets the tags from this Item
   * @type {string[]}
   */
  get tags () {
    return utils.javaSetToJsArray(this.rawItem.getTags());
  }

  /**
   * Adds tags to this Item
   * @param {...string} tagNames names of the tags to add
   */
  addTags (...tagNames) {
    this.rawItem.addTags(tagNames);
    itemRegistry.update(this.rawItem);
  }

  /**
   * Removes tags from this Item
   * @param {...string} tagNames names of the tags to remove
   */
  removeTags (...tagNames) {
    for (const tagName of tagNames) {
      this.rawItem.removeTag(tagName);
    }
    itemRegistry.update(this.rawItem);
  }

  toString () {
    return this.rawItem.toString();
  }
}

/**
 * Creates a new Item object. This Item is not registered with any provider and therefore can not be accessed.
 *
 * Note that all Items created this way have an additional tag attached, for simpler retrieval later. This tag is
 * created with the value {@link DYNAMIC_ITEM_TAG}.
 *
 * @private
 * @param {ItemConfig} itemConfig the Item config describing the Item
 * @returns {Item} {@link items.Item}
 * @throws {Error} {@link ItemConfig}.name or {@link ItemConfig}.type not set
 * @throws failed to create Item
 */
function _createItem (itemConfig) {
  if (typeof itemConfig.name !== 'string' || typeof itemConfig.type !== 'string') throw Error('itemConfig.name or itemConfig.type not set');

  itemConfig.name = safeItemName(itemConfig.name);

  let baseItem;
  if (itemConfig.type === 'Group' && typeof itemConfig.giBaseType !== 'undefined') {
    baseItem = itemBuilderFactory.newItemBuilder(itemConfig.giBaseType, itemConfig.name + '_baseItem').build();
  }
  if (itemConfig.type !== 'Group') {
    itemConfig.groupFunction = undefined;
  }

  if (typeof itemConfig.tags === 'undefined') {
    itemConfig.tags = [];
  }
  itemConfig.tags.push(DYNAMIC_ITEM_TAG);

  try {
    let builder = itemBuilderFactory.newItemBuilder(itemConfig.type, itemConfig.name)
      .withCategory(itemConfig.category)
      .withLabel(itemConfig.label)
      .withTags(utils.jsArrayToJavaSet(itemConfig.tags));

    if (typeof itemConfig.groups !== 'undefined') {
      builder = builder.withGroups(utils.jsArrayToJavaList(itemConfig.groups));
    }

    if (typeof baseItem !== 'undefined') {
      builder = builder.withBaseItem(baseItem);
    }
    if (typeof itemConfig.groupFunction !== 'undefined') {
      builder = builder.withGroupFunction(itemConfig.groupFunction);
    }

    const item = builder.build();
    return new Item(item);
  } catch (e) {
    log.error('Failed to create Item: ' + e);
    throw e;
  }
}

/**
 * Creates a new Item within OpenHab. This Item will persist to the registry, and therefore is independent of the lifecycle of the script creating it.
 *
 * Note that all Items created this way have an additional tag attached, for simpler retrieval later. This tag is
 * created with the value {@link DYNAMIC_ITEM_TAG}.
 *
 * @memberof items
 * @param {ItemConfig} itemConfig the Item config describing the Item
 * @returns {Item} {@link Items.Item}
 * @throws {Error} if {@link ItemConfig}.name or {@link ItemConfig}.type is not set
 * @throws {Error} if failed to create Item
 */
function addItem (itemConfig) {
  const item = _createItem(itemConfig);
  itemRegistry.add(item.rawItem);

  if (typeof itemConfig.metadata === 'object') {
    const namespace = Object.keys(itemConfig.metadata);
    for (const i in namespace) {
      const namespaceValue = itemConfig.metadata[namespace[i]];
      log.debug('addItem: Processing metadata namespace {}', namespace[i]);
      if (typeof namespaceValue === 'string') { // namespace as key and it's value as value
        metadata.replaceMetadata(itemConfig.name, namespace[i], namespaceValue);
      } else if (typeof namespaceValue === 'object') { // namespace as key and { value: 'string', configuration: object } as value
        metadata.replaceMetadata(itemConfig.name, namespace[i], namespaceValue.value, namespaceValue.config);
      }
    }
  }

  if (itemConfig.type !== 'Group') {
    if (typeof itemConfig.channels === 'string') { // single channel link with string
      metadata.itemchannellink.replaceItemChannelLink(itemConfig.name, itemConfig.channels);
    } else if (typeof itemConfig.channels === 'object') { // multiple/complex channel links with channel as key and config object as value
      const channels = Object.keys(itemConfig.channels);
      for (const i in channels) metadata.itemchannellink.replaceItemChannelLink(itemConfig.name, channels[i], itemConfig.channels[channels[i]]);
    }
  }

  return getItem(itemConfig.name);
}

/**
 * Removes an Item from openHAB. The Item is removed immediately and cannot be recovered.
 *
 * @memberof items
 * @param {string|Item} itemOrItemName the Item or the name of the Item to remove
 * @returns {Item|null} the Item that has been removed or `null` if it has not been removed
 */
function removeItem (itemOrItemName) {
  let itemName;

  if (typeof itemOrItemName === 'string') {
    itemName = itemOrItemName;
  } else if (_isItem(itemOrItemName)) {
    itemName = itemOrItemName.name;
  } else {
    log.warn('Item name is undefined (no Item supplied or supplied name is not a string) so cannot be removed');
    return false;
  }

  let item;
  try { // If the Item is not registered, ItemNotFoundException is thrown.
    item = getItem(itemName);
  } catch (e) {
    if (Java.typeName(e.getClass()) === 'org.openhab.core.items.ItemNotFoundException') {
      log.error('Item {} not registered so cannot be removed: {}', itemName, e.message);
      return null;
    } else { // If exception/error is not ItemNotFoundException, rethrow.
      throw Error(e);
    }
  }

  itemRegistry.remove(itemName);

  try { // If the Item has been successfully removed, ItemNotFoundException is thrown.
    itemRegistry.getItem(itemName);
    log.warn('Failed to remove Item: {}', itemName);
    return null;
  } catch (e) {
    if (Java.typeName(e.getClass()) === 'org.openhab.core.items.ItemNotFoundException') {
      return item;
    } else { // If exception/error is not ItemNotFoundException, rethrow.
      throw Error(e);
    }
  }
}

/**
 * Replaces (or adds) an Item. If an Item exists with the same name, it will be removed and a new Item with
 * the supplied parameters will be created in its place. If an Item does not exist with this name, a new
 * Item will be created with the supplied parameters.
 *
 * This function can be useful in scripts which create a static set of Items which may need updating either
 * periodically, during startup or even during development of the script. Using fixed Item names will ensure
 * that the Items remain up-to-date, but won't fail with issues related to duplicate Items.
 *
 * @memberof items
 * @param {ItemConfig} itemConfig the Item config describing the Item
 * @returns {Item|null} the old Item or `null` if it did not exist
 * @throws {Error} {@link ItemConfig}.name or {@link ItemConfig}.type not set
 * @throws failed to create Item
 */
function replaceItem (itemConfig) {
  const item = getItem(itemConfig.name, true);
  if (item !== null) { // Item already existed
    removeItem(itemConfig.name);
  }
  addItem(itemConfig);
  return item;
}

/**
 * Whether an Item with the given name exists.
 * @memberof items
 * @param {string} name the name of the Item
 * @returns {boolean} whether the Item exists
 */
function existsItem (name) {
  try {
    itemRegistry.getItem(name);
    return true;
  } catch (e) {
    return false;
  }
}

/**
 * Gets an openHAB Item.
 * @memberof items
 * @param {string} name the name of the Item
 * @param {boolean} [nullIfMissing=false] whether to return null if the Item cannot be found (default is to throw an {@link https://www.openhab.org/javadoc/latest/org/openhab/core/items/itemnotfoundexception ItemNotFoundException})
 * @returns {Item} {@link items.Item} Item or `null` if `nullIfMissing` is true and Item is missing
 */
function getItem (name, nullIfMissing = false) {
  try {
    return new Item(itemRegistry.getItem(name));
  } catch (e) {
    if (nullIfMissing) {
      return null;
    } else {
      throw e;
    }
  }
}

/**
 * Gets all openHAB Items.
 *
 * @memberof items
 * @returns {Item[]} {@link items.Item}[]: all Items
 */
function getItems () {
  return utils.javaSetToJsArray(itemRegistry.getItems()).map(i => new Item(i));
}

/**
 * Gets all openHAB Items with a specific tag.
 *
 * @memberof items
 * @param {string[]} tagNames an array of tags to match against
 * @returns {Item[]} {@link items.Item}[]: the Items with a tag that is included in the passed tags
 */
function getItemsByTag (...tagNames) {
  return utils.javaSetToJsArray(itemRegistry.getItemsByTag(tagNames)).map(i => new Item(i));
}

/**
 * Helper function to ensure an Item name is valid. All invalid characters are replaced with an underscore.
 * @memberof items
 * @param {string} s the name to make value
 * @returns {string} a valid Item name
 */
const safeItemName = (s) => s
  .replace(/["']/g, '') // delete
  .replace(/[^a-zA-Z0-9]/g, '_'); // replace with underscore

const itemProperties = {
  safeItemName,
  existsItem,
  getItem,
  getItems,
  addItem,
  getItemsByTag,
  replaceItem,
  removeItem,
  Item,
  metadata,
  TimeSeries
};

/**
 * Gets an openHAB Item by name directly on the {@link items} namespace.
 * Equivalent to {@link items.getItem}
 *
 * @example
 * // retrieve item by name directly on the items namespace
 * console.log(items.KitchenLight.state) // returns 'ON'
 * // equivalent to
 * console.log(items.getItem('KitchenLight').state) // returns 'ON'
 *
 * @name NAME
 * @memberof items
 * @function
 * @returns {Item|null} {@link items.Item} Item or `null` if Item is missing
 */
module.exports = new Proxy(itemProperties, {
  get: function (target, prop) {
    return target[prop] || target.getItem(prop, true);
  }
});