thingsx/mlsc.js

/**
 * Copyright (c) 2023 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 { actions, items, rules, triggers } = require('openhab');
// @ts-ignore
const HSBType = Java.type('org.openhab.core.library.types.HSBType'); // eslint-disable-line no-unused-vars

// typedefs need to be global for TypeScript to fully work
/**
 * @typedef {Object} MlscRestClientConfig configuration for {@link MlscRestClient}
 *
 * @property {string} effectItemName name of the effect Item: Do NOT set state description metadata on that Item, this will be done for you.
 * @property {string} url full URL for mlsc, e.g. `http://127.0.0.1:8080`
 * @property {string} deviceId ID of device inside mlsc, use HTTP GET `/api/system/devices` to get a list of available devices
 * @property {string} [colorItemName] name of the color Item
 * @property {string} [dimmerItemName] name of the dimmer Item
 * @property {string} [defaultEffect='effect_gradient'] default effect for the `Dimmer` Item
 * @property {number} [refreshInterval=15000] refresh interval in milliseconds
 * @property {number} [switchOnDelay] switch-on delay in milliseconds, e.g. useful if power multiple power supplies with different power-on times are used
 */

/**
 * A `MlscApiError` is thrown when a {@link MlscApi} operation fails.
 *
 * @private
 */
class MlscApiError extends Error {
  /**
   * @param {string} message
   */
  constructor (message) {
    super(message);
    super.name = 'MlscApiError';
  }
}

/**
 * The `MlscApi` class provides access to the REST API of music led strip control.
 *
 * @private
 */
class MlscApi {
  static #HEADERS = new Map([['accept', 'application/json']]);
  /**
   * All available non music and music effects.
   *
   * @type {{music: object, non_music: object}}
   */
  static effects;

  #baseUrl;
  #deviceId;
  #prettyName;

  /**
   * @param {string} url full URL of the music led strip control server, e.g. `http://127.0.0.1:8080`
   * @param {string} deviceId id of device inside mlsc, use HTTP GET `/api/system/devices` to get a list of available devices
   */
  constructor (url, deviceId) {
    // Validate parameters
    if (typeof url !== 'string') throw new Error('url must be a string!');
    if (typeof deviceId !== 'string') throw new Error('deviceId must be a string!');

    // Initialize private fields
    this.#baseUrl = url + '/api/';
    this.#deviceId = deviceId;
    this.#prettyName = `${deviceId} on ${url}`;

    // Initialize static field effects
    if (!MlscApi.effects) MlscApi.effects = this.#getAvailableEffects();
  }

  #getAvailableEffects () {
    console.debug(`Getting available effects from ${this.#prettyName} ...`);
    try {
      const response = actions.HTTP.sendHttpGetRequest(this.#baseUrl + 'resources/effects', MlscApi.#HEADERS, 1000);
      return JSON.parse(response);
    } catch (e) {
      throw new MlscApiError('Failed to get available effects: ' + e);
    }
  }

  #getEffect () {
    console.debug(`Getting effect from ${this.#prettyName} ...`);
    try {
      const response = actions.HTTP.sendHttpGetRequest(this.#baseUrl + 'effect/active?device=' + this.#deviceId, MlscApi.#HEADERS, 1000);
      const json = JSON.parse(response);
      return json.effect;
    } catch (e) {
      throw new MlscApiError('Failed to get effect: ' + e);
    }
  }

  #getBrightness () {
    console.debug(`Getting brightness from ${this.#prettyName} ...`);
    try {
      const response = actions.HTTP.sendHttpGetRequest(`${this.#baseUrl}/settings/device?device=${this.#deviceId}&setting_key=led_brightness`, MlscApi.#HEADERS, 1000);
      const json = JSON.parse(response);
      return parseInt(json.setting_value);
    } catch (e) {
      throw new MlscApiError('Failed to get brightness: ' + e);
    }
  }

  #getColor () {
    console.debug(`Getting color from ${this.#prettyName} ...`);
    try {
      const response = actions.HTTP.sendHttpGetRequest(this.#baseUrl + 'settings/effect?effect=effect_single&device=' + this.#deviceId, MlscApi.#HEADERS, 1000);
      const json = JSON.parse(response);
      const rgb = json.settings.custom_color;
      return HSBType.fromRGB(rgb[0], rgb[1], rgb[2]);
    } catch (e) {
      throw new MlscApiError('Failed to get color: ' + e);
    }
  }

  /**
   * Set the effect.
   * If the passed in effect is invalid, an error is thrown.
   *
   * @param {string} effect new effect
   * @throws MlscApiError if effect is invalid or the API request failed
   */
  setEffect (effect) {
    console.debug(`Setting effect of ${this.#prettyName} to ${effect} ...`);

    if (!(Object.keys(MlscApi.effects.music).concat(Object.keys(MlscApi.effects.non_music)).includes(effect) || effect === 'effect_off')) {
      throw new MlscApiError('Failed to set effect: Invalid value ' + effect);
    }

    try {
      actions.HTTP.sendHttpPostRequest(this.#baseUrl + 'effect/active', 'application/json', JSON.stringify({
        device: this.#deviceId,
        effect
      }));
    } catch (e) {
      throw new MlscApiError('Failed to set effect: ' + e);
    }
  }

  /**
   * Set the brightness.
   * If the passed in value is not `ON`, `OFF` or an integer between 0 and 100, an error is thrown.
   *
   * @param {number|string} brightness brightness value as integer between 0 and 100 or `ON` or `OFF`
   * @throws MlscApiError if brightness is invalid or the API request failed
   */
  setBrightness (brightness) {
    console.debug(`Setting brightness of ${this.#prettyName} to ${brightness} ...`);
    if (brightness === 'OFF') brightness = 0;
    if (brightness === 'ON') brightness = 100;

    // @ts-ignore
    const intValue = parseInt(brightness);
    if (isNaN(intValue) || intValue < 0 || intValue > 100) {
      throw new MlscApiError('Failed to set brightness: Invalid value ' + brightness);
    }

    try {
      actions.HTTP.sendHttpPostRequest(this.#baseUrl + 'settings/device', 'application/json', JSON.stringify({
        device: this.#deviceId,
        settings: {
          led_brightness: intValue
        }
      }));
    } catch (e) {
      throw new MlscApiError('Failed to set brightness: ' + e);
    }
  }

  /**
   * Set the color.
   * If the passed in value is not a HSBType, an error is thrown.
   *
   * @param {*} hsb instance of {@link https://www.openhab.org/javadoc/latest/org/openhab/core/library/types/hsbtype org.openhab.core.library.types.HSBType}
   * @throws MlscApiError if hsb is no HSBType or the API request failed
   */
  setColor (hsb) {
    console.debug(`Setting color of ${this.#prettyName} to ${hsb} ...`);
    if (!(hsb instanceof HSBType)) {
      throw new MlscApiError('Failed to set color: hsb must an instance of "org.openhab.core.library.types.HSBType"');
    }

    // @ts-ignore
    const r = parseInt(hsb.getRed() * 2.55);
    // @ts-ignore
    const g = parseInt(hsb.getGreen() * 2.55);
    // @ts-ignore
    const b = parseInt(hsb.getBlue() * 2.55);

    try {
      actions.HTTP.sendHttpPostRequest(this.#baseUrl + 'settings/effect', 'application/json', JSON.stringify({
        device: this.#deviceId,
        effect: 'effect_single',
        settings: {
          custom_color: [r, g, b],
          use_custom_color: true
        }
      }));
    } catch (e) {
      throw new MlscApiError('Failed to set color: ' + e);
    }
  }

  /**
   * Get the current, processed state.
   *
   * @returns {{brightness: number, color: null, effect: (string|any)}|{brightness: number, color: *, effect: (string|any|AnimationEffect)}}
   */
  getProcessedState () {
    console.debug(`Getting state from ${this.#prettyName} ...`);
    const effect = this.#getEffect();
    if (effect === 'effect_off') {
      return {
        effect,
        brightness: 0,
        color: null
      };
    }
    return {
      effect,
      brightness: this.#getBrightness(),
      color: this.#getColor()
    };
  }
}

/**
 * music_led_strip_control REST client
 *
 * Class providing state fetching from and command sending to the REST API of {@link https://github.com/TobKra96/music_led_strip_control music_led_strip_control}.
 * It is using a scheduled job to fetch states and a rule to handle commands.
 *
 * @example
 * var { thingsx } = require('@hotzware/openhab-tools');
 * var mlsc = new thingsx.MlscRestClient({
 *   effectItemName: 'FlorianRGB_effect',
 *   url: 'http://127.0.0.1:8080',
 *   deviceId: 'device_0',
 *   colorItemName: 'FlorianRGB_color',
 *   dimmerItemName: 'FlorianRGB_dimmer'
 * });
 * mlsc.scheduleStateFetching();
 * mlsc.createCommandHandlingRule();
 *
 * @memberof thingsx
 */
class MlscRestClient {
  #config;
  #prettyName;
  #api;
  #effect = null;
  #lastEffect;

  /**
   * Be aware that you need to call {@link scheduleStateFetching} and {@link createCommandHandlingRule} to fully initialize the REST client.
   *
   * @param {MlscRestClientConfig} config mlsc REST client config
   */
  constructor (config) {
    // Validate parameters
    if (typeof config.effectItemName !== 'string') throw new Error('effectItemName must be a string!');
    if (config.colorItemName && typeof config.colorItemName !== 'string') throw new Error('colorItemName must be a string!');
    if (config.dimmerItemName && typeof config.dimmerItemName !== 'string') throw new Error('dimmerItemName must be a string!');
    if (config.defaultEffect && typeof config.defaultEffect !== 'string') throw new Error('defaultEffect must be a string!');
    if (config.refreshInterval && typeof config.refreshInterval !== 'number') throw new Error('refreshInterval must be a number!');
    if (config.switchOnDelay && typeof config.switchOnDelay !== 'number') throw new Error('switchOnDelay must be a number!');

    // Fallback to defaults
    if (!config.defaultEffect) config.defaultEffect = 'effect_gradient';
    if (!config.refreshInterval) config.refreshInterval = 15000;

    // Initialize private fields
    this.#config = config;
    this.#lastEffect = config.defaultEffect;
    this.#prettyName = `${this.#config.deviceId} of ${this.#config.url}`;

    // Initialize API
    this.#api = new MlscApi(config.url, config.deviceId);

    // Set command/state description metadata on effect Item
    this.#setStateDescription();
  }

  #setStateDescription () {
    console.info(`Setting state description of ${this.#config.effectItemName} to available effects ...`);
    let options = '"effect_off"="Off", ';
    for (const [key, value] of Object.entries(MlscApi.effects.non_music)) {
      options += `"${key}"="${value}", `;
    }
    for (const [key, value] of Object.entries(MlscApi.effects.music)) {
      options += `"${key}"="Music - ${value}", `;
    }
    options = options.substring(0, options.length - 2); // Remove last " ,"
    items.metadata.replaceMetadata(this.#config.effectItemName, 'stateDescription', '', {
      options
    });
  }

  #updateState () {
    let state;
    try {
      state = this.#api.getProcessedState();
    } catch (e) {
      if (e instanceof MlscApiError) {
        console.warn(e);
        return;
      }
      throw e;
    }
    this.#effect = state.effect;
    if (state.effect !== 'effect_off') this.#lastEffect = state.effect;
    items.getItem(this.#config.effectItemName).postUpdate(state.effect);

    if (this.#config.colorItemName && state.color) items.getItem(this.#config.colorItemName).postUpdate(state.color.toString());
    if (this.#config.dimmerItemName) items.getItem(this.#config.dimmerItemName).postUpdate(state.brightness);
  }

  /**
   * Schedules the state fetching using `setInterval`.
   *
   * @returns {NodeJS.Timeout} `intervalId` of the interval used for state fetching
   */
  scheduleStateFetching () {
    console.info(`Initializing state fetching for ${this.#prettyName} ...`);
    return setInterval(() => {
      this.#updateState();
    }, this.#config.refreshInterval);
  }

  #handleEffectCommand (effect) {
    try {
      this.#api.setEffect(effect);
    } catch (e) {
      if (e instanceof MlscApiError) {
        console.warn(e);
        return;
      }
      throw e;
    }
    this.#updateState();
  }

  /**
   * Creates the rule used for command handling.
   */
  createCommandHandlingRule () {
    const ruleConfig = {
      name: `mlsc REST client for "${this.#config.deviceId}" of "${this.#config.url}"`,
      description: 'Provides command handling, state fetching is done by a scheduled job',
      triggers: [
        triggers.ItemCommandTrigger(this.#config.effectItemName)
      ],
      execute: (event) => {
        console.debug(`Handling command ${event.receivedCommand} of ${event.itemName} for ${this.#prettyName} ...`);
        // Handle effect control
        if (event.itemName === this.#config.effectItemName) {
          if (this.#effect === 'effect_off' && event.receivedCommand !== 'effect_off' && this.#config.switchOnDelay) {
            setTimeout(() => {
              this.#handleEffectCommand(event.receivedCommand);
            }, this.#config.switchOnDelay);
          } else {
            this.#handleEffectCommand(event.receivedCommand);
          }

          // Handle color control
        } else if (event.itemName === this.#config.colorItemName) {
          const hsb = HSBType.valueOf(event.receivedCommand);
          try {
            this.#api.setColor(hsb);
          } catch (e) {
            if (e instanceof MlscApiError) {
              console.warn(e);
              return;
            }
            throw e;
          }
          items.getItem(this.#config.effectItemName).sendCommandIfDifferent('effect_single');
          this.#updateState();

          // Handle dimmer control
        } else if (this.#config.dimmerItemName && event.itemName === this.#config.dimmerItemName) {
          if (event.receivedCommand === 'OFF' || event.receivedCommand === '0') {
            items.getItem(this.#config.effectItemName).sendCommandIfDifferent('effect_off');
          } else {
            try {
              this.#api.setBrightness(event.receivedCommand);
            } catch (e) {
              if (e instanceof MlscApiError) {
                console.warn(e);
                return;
              }
              throw e;
            }
            // Turn on the stripes if needed
            if (this.#effect === 'effect_off') {
              items.getItem(this.#config.effectItemName).sendCommandIfDifferent(this.#lastEffect);
            }
          }
          this.#updateState();
        }
      },
      id: `mlsc-rest-client-for-${this.#config.dimmerItemName || this.#config.effectItemName}`,
      tags: ['@hotzware/openhab-tools', 'MlscRestClient', 'music_led_strip_control']
    };
    // Add colorItem as trigger (if defined)
    if (this.#config.colorItemName) ruleConfig.triggers.push(triggers.ItemCommandTrigger(this.#config.colorItemName));
    // Add dimmerItem as trigger (if defined)
    if (this.#config.dimmerItemName) ruleConfig.triggers.push(triggers.ItemCommandTrigger(this.#config.dimmerItemName));
    console.info(`Creating command handling rule for ${this.#prettyName} ...`);
    rules.JSRule(ruleConfig);
  }

  /**
   * Get all available music and non-music effects.
   *
   * @returns {{music: Object, non_music: Object}}
   */
  getAvailableEffects () {
    return MlscApi.effects;
  }
}

module.exports = {
  MlscRestClient
};