Source

system-access-point.ts

import { Observable, Subject } from "rxjs";
import { ClientOptions, RawData, WebSocket } from "ws";
import { EventEmitter } from "events";
import {
  Configuration,
  DeviceList,
  DeviceResponse,
  GetDataPointResponse,
  isDeviceList,
  isDeviceResponse,
  isWebSocketMessage,
  isGetDataPointResponse,
  isSetDataPointResponse,
  isConfiguration,
  SetDataPointResponse,
  WebSocketMessage,
  VirtualDevice,
  VirtualDeviceResponse,
  Logger,
} from "./model";
import { isVirtualDeviceResponse } from "./model/validator";

/** The HTTP request method */
type HttpRequestMethod = "GET" | "POST" | "DELETE" | "PATCH" | "PUT";

/** The class representing a System Access Point. */
export class SystemAccessPoint extends EventEmitter {
  /** The basic authentication key used for requests. */
  public readonly basicAuthKey: string;
  /** The host name of the system access point. */
  public readonly hostName: string;
  /** Determines whether requests to the system access point will use TLS. */
  public readonly tlsEnabled: boolean;
  private readonly logger: Logger;
  private verboseErrors: boolean;
  private webSocket?: WebSocket;
  private readonly webSocketMessageSubject = new Subject<WebSocketMessage>();

  /**
   * Constructs a new SystemAccessPoint instance
   *
   * @constructor
   * @param hostName {string} The system access point host name.
   * @param userName {string} The user name that shall be used to authenticate with the system access point.
   * @param password {string} The password that shall be used to authenticate with the system access point.
   * @param tlsEnabled {boolean} Determines whether the communication with the system access point shall be protected by TLS.
   * @param verboseErrors {boolean} Determines whether verbose error messages shall be used, for example for message validation.
   * @param logger {Logger} The logger instance to be used. If no explicit implementation is provided, the this.logger will be used for logging.
   */
  constructor(
    hostName: string,
    userName: string,
    password: string,
    tlsEnabled = true,
    verboseErrors = false,
    logger?: Logger
  ) {
    super();

    // Configure logging
    this.logger = logger ?? console;

    // Create Basic Authentication key
    this.basicAuthKey = Buffer.from(`${userName}:${password}`, "utf8").toString(
      "base64"
    );
    this.hostName = hostName;
    this.tlsEnabled = tlsEnabled;
    this.verboseErrors = verboseErrors;
  }

  /**
   * Connects to the System Access Point web socket.
   * @param certificateVerification {boolean} Determines whether the TLS certificate presented by the server will be verified.
   */
  public connectWebSocket(certificateVerification = true): void {
    if (this.webSocket && this.webSocket.readyState === WebSocket.OPEN) {
      throw new Error("Web socket is already connected");
    }

    this.webSocket = this.createWebSocket(certificateVerification);
  }

  /**
   * Creates a new virtual device.
   * @param sysApUuid {string} The UUID identifying the system access point.
   * @param deviceSerial {string} The serial number to be assigned to the device.
   * @param virtualDevice {VirtualDevice} The virtual device to be created.
   * @returns {Promise.<VirtualDeviceResponse>} The response to the virtual device request.
   */
  public async createVirtualDevice(
    sysApUuid: string,
    deviceSerial: string,
    virtualDevice: VirtualDevice
  ): Promise<VirtualDeviceResponse> {
    // Get response from system access point
    const response: Response = await this.fetchDataViaRest(
      "PUT",
      `virtualdevice/${sysApUuid}/${deviceSerial}`,
      JSON.stringify(virtualDevice)
    );

    // Process response
    return this.processRestResponse(response, isVirtualDeviceResponse);
  }

  private createWebSocket(certificateVerification: boolean): WebSocket {
    // Disabling certificate verification is discouraged, issue a warning
    if (this.tlsEnabled && !certificateVerification) {
      this.logger.warn(
        "TLS certificate verification is disabled! This poses a security risk, activating certificate verification is strictly recommended."
      );
    }

    const url = `${this.tlsEnabled ? "wss" : "ws"}://${
      this.hostName
    }/fhapi/v1/api/ws`;
    const options: ClientOptions = {
      rejectUnauthorized: this.tlsEnabled && certificateVerification,
      headers: {
        Authorization: `Basic ${this.basicAuthKey}`,
      },
    };
    const webSocket = new WebSocket(url, options);
    webSocket.on("error", (error: Error) => {
      this.emit("websocket-error", error);
      this.logger.error("Error received", error);
    });
    webSocket.on("ping", (data: Buffer) => {
      this.emit("websocket-ping", data);
      this.logger.debug("Ping received", data.toString("ascii"));
    });
    webSocket.on("pong", (data: Buffer) => {
      this.emit("websocket-pong", data);
      this.logger.debug("Pong received", data.toString("ascii"));
    });
    webSocket.on("unexpected-response", (request, response) => {
      this.emit("websocket-unexpected-response", request, response);
      this.logger.error("Unexpected response received");
    });
    webSocket.on("upgrade", (request) => {
      this.emit("websocket-upgrade", request);
      this.logger.debug("Upgrade request received");
    });
    webSocket.on("open", () => {
      this.emit("websocket-open");
      this.logger.log("Connection opened");
    });
    webSocket.on("close", (code, reason) => {
      this.emit("websocket-close", code, reason);
      this.logger.log("Connection closed");
    });
    webSocket.on("message", (data: RawData, isBinary: boolean) => {
      this.emit("websocket-message", data, isBinary);
      this.processWebSocketMessage(data, isBinary);
    });
    return webSocket;
  }

  /**
   * Disconnects from the System Access Point web socket.
   * @param force {boolean} Determines whether or not the connection will be closed forcibly.
   */
  public disconnectWebSocket(force = false): void {
    if (!this.webSocket || this.webSocket.readyState === WebSocket.CLOSED) {
      throw new Error("Web socket is not open");
    }

    if (force) {
      this.webSocket.terminate();
    } else {
      this.webSocket.close();
    }
  }

  /**
   * Gets the configuration from the system access point.
   * @returns {Promise.<Configuration>} The system access point configuration.
   */
  public async getConfiguration(): Promise<Configuration> {
    // Get response from system access point
    const response: Response = await this.fetchDataViaRest(
      "GET",
      "configuration"
    );

    // Process response
    return this.processRestResponse(response, isConfiguration);
  }

  /**
   * Gets the device list from the system access point.
   * @returns {Promise.<DeviceList>} The requested device list.
   */
  public async getDeviceList(): Promise<DeviceList> {
    // Get response from system access point
    const response: Response = await this.fetchDataViaRest("GET", "devicelist");

    // Process response
    return this.processRestResponse(response, isDeviceList);
  }

  /**
   * Gets the specified device from the system access point.
   * @param sysApUuid {string} The UUID identifying the system access point.
   * @param deviceSerial {string} The device serial number.
   * @returns {Promise.<DeviceResponse>} The response to the device request.
   */
  public async getDevice(
    sysApUuid: string,
    deviceSerial: string
  ): Promise<DeviceResponse> {
    // Get response from system access point
    const response: Response = await this.fetchDataViaRest(
      "GET",
      `device/${sysApUuid}/${deviceSerial}`
    );

    // Process response
    return this.processRestResponse(response, isDeviceResponse);
  }

  /**
   * Gets the specified data point from the system access point.
   * @param sysApUuid {string} The UUID idenfifying the system access point.
   * @param deviceSerial {string} The device serial number.
   * @param channel {string} The channel identifier.
   * @param dataPoint {string} The datapoint identifier.
   * @returns {Promise.<GetDataPointResponse>} The response to the get data point request.
   */
  public async getDatapoint(
    sysApUuid: string,
    deviceSerial: string,
    channel: string,
    dataPoint: string
  ): Promise<GetDataPointResponse> {
    // Get response from system access point
    const response: Response = await this.fetchDataViaRest(
      "GET",
      `datapoint/${sysApUuid}/${deviceSerial}.${channel}.${dataPoint}`
    );

    // Process response
    return this.processRestResponse(response, isGetDataPointResponse);
  }

  /**
   * Gets the web socket messages.
   * @returns {Observable.<WebSocketMessage>} An observable that is updated with the messages received from the web socket.
   */
  public getWebSocketMessages(): Observable<WebSocketMessage> {
    return this.webSocketMessageSubject.asObservable();
  }

  /**
   * Sets a new value for the specificed data point.
   * @param sysApUuid {string} The UUID idenfifying the system access point.
   * @param deviceSerial {string} The device serial number.
   * @param channel {string} The channel identifier.
   * @param dataPoint {string} The datapoint identifier.
   * @param value {string} The new value to be set.
   * @returns {Promise.<SetDataPointResponse>} The response to the set data point request.
   */
  public async setDatapoint(
    sysApUuid: string,
    deviceSerial: string,
    channel: string,
    dataPoint: string,
    value: string
  ): Promise<SetDataPointResponse> {
    // Get response from system access point
    const response: Response = await this.fetchDataViaRest(
      "PUT",
      `datapoint/${sysApUuid}/${deviceSerial}.${channel}.${dataPoint}`,
      value
    );

    // Process response
    return this.processRestResponse(response, isSetDataPointResponse);
  }

  /**
   * Triggeres the given action for the specified proxy device. Please note that this method is part of the experimental API!
   * @param sysApUuid {string} The UUID idenfifying the system access point.
   * @param deviceClass {string} The device class.
   * @param deviceSerial {string} The device serial number.
   * @param action {string} The action to be triggered.
   * @returns {Promise.<DeviceResponse>} The response to the request.
   */
  public async triggerProxyDevice(
    sysApUuid: string,
    deviceClass: string,
    deviceSerial: string,
    action: string
  ): Promise<DeviceResponse> {
    // Get response from system access point
    const response: Response = await this.fetchDataViaRest(
      "GET",
      `proxydevice/${sysApUuid}/${deviceClass}/${deviceSerial}/action/${action}`
    );

    // Process response
    return this.processRestResponse(response, isDeviceResponse);
  }

  /**
   * Sets the given value for the specified proxy device. Please note that this method is part of the experimental API!
   * @param sysApUuid {string} The UUID idenfifying the system access point.
   * @param deviceClass {string} The device class.
   * @param deviceSerial {string} The device serial number.
   * @param value {string} The value to be set.
   * @returns {Promise.<DeviceResponse>} The response to the request.
   */
  public async setProxyDeviceValue(
    sysApUuid: string,
    deviceClass: string,
    deviceSerial: string,
    value: string
  ): Promise<DeviceResponse> {
    // Get response from system access point
    const response: Response = await this.fetchDataViaRest(
      "PUT",
      `proxydevice/${sysApUuid}/${deviceClass}/${deviceSerial}/value/${value}`
    );

    // Process response
    return this.processRestResponse(response, isDeviceResponse);
  }

  private async fetchDataViaRest(
    method: HttpRequestMethod,
    route: string,
    body: BodyInit | null | undefined = undefined
  ): Promise<Response> {
    // Set up request info
    const info: RequestInfo = `${this.tlsEnabled ? "https" : "http"}://${
      this.hostName
    }/fhapi/v1/api/rest/${route}`;

    // Set up request init
    const init: RequestInit = {
      method: method,
      headers: {
        Authorization: `Basic ${this.basicAuthKey}`,
      },
      body: body,
    };

    // Get response from system access point
    return fetch(info, init);
  }

  private async processRestResponse<TResponse>(
    response: Response,
    typeGuard: (
      obj: unknown,
      logger: Logger,
      verbose: boolean
    ) => obj is TResponse
  ): Promise<TResponse> {
    let body: unknown;
    let message: string;

    // Process response
    switch (response.status) {
      case 200:
        body = await response.json();
        if (!typeGuard(body, this.logger, this.verboseErrors)) {
          message = "Received message has an unexpected type!";
          this.logger.error(message, body);
          throw new Error(message);
        }

        return body;
      case 401:
        message = "Authentication information is missing or invalid.";
        this.logger.error(message);
        throw new Error(message);
      case 502:
        message = await response.text();
        this.logger.error(message);
        throw new Error(message);
      default:
        message = `Received HTTP ${
          response.status
        } status code unexpectedly: ${await response.text()}`;
        this.logger.error(message);
        throw new Error(message);
    }
  }

  private processWebSocketMessage(data: RawData, isBinary: boolean): void {
    // Do not process binary messages
    if (isBinary) {
      this.logger.warn(
        "Binary message received. Binary messages are not processed."
      );
      return;
    }

    this.logger.debug("Message received");
    /*
     * Deserialize the message.
     * The message is expected to be deserializable as a web socket message.
     * If that is not the case, that's an error case.
     */
    // eslint-disable-next-line @typescript-eslint/no-base-to-string
    const serialized = data.toString();
    const message: unknown = JSON.parse(serialized);

    if (isWebSocketMessage(message, this.logger, this.verboseErrors)) {
      this.webSocketMessageSubject.next(message);
      return;
    }

    this.logger.error("Received message has an unexpected type!", serialized);
  }
}