Device.js

import {
  BASE_URL,
  POST,
  DELETE,
  DEFAULT_STREAM_OPT,
  DEFAULT_CAPTURE_OPT,
  checkResponse,
  timeout,
  urlParamsToString
} from './Common.js'

/**
 * Device
 * @classdesc Class for interacting with a Device returned from a plugin.
 */
class Device {
  /*
   * @property {Object} device - Device object as received from the API
   * @property {string} device.id - URL safe device identified, this can often be hashed or encoded and should be considered opaque.
   * @property {string?} device.raw_id - When possible, if the id is of some encoded or hashed type, this is the underlying value and can be shown to users.
   * @property {string} device.name - Human friendly name of the device.
   */
  baseUrl
  plugin
  id
  raw_id
  name

  /**
   * Instantiate a device.
   * @constructor
   * @param {object} device - Device object as received from the API
   * @param {string} device.id - URL safe device identified, this can often be hashed or encoded and should be considered opaque.
   * @param {string?} device.raw_id - When possible, if the id is of some encoded or hashed type, this is the underlying value and can be shown to users.
   * @param {string} device.name - Human friendly name of the device.
   * @param {object} plugin - Plugin serviced by this device.
   * @param {string} [baseURL] - Protocol, domain, and port for the service.
   */
  constructor (properties, plugin, baseUrl = BASE_URL) {
    this.baseUrl = baseUrl
    this.plugin = plugin
    Object.assign(this, {}, properties)
  }

  /**
   * Will throw an exception if a plugin cannot perform the provided RPC command
   * @param {string} method - The JSON RPC method to check.
   */
  can (method) {
    if (this.plugin.methods.indexOf(method) === -1) {
      throw new Error(`${method} not supported for this device`)
    }
  }

  /**
   * Will throw an exception if the plugin cannot performa at least one of the
   * provided JSON RPC commandss
   * @param {string[]} methods - The JSON RPC methods to check.
   */
  canDoOne (methods) {
    if (!methods.some(m => this.plugin.methods.includes(m))) {
      throw new Error(`Device does not support any of the methods: ${methods.join(',')}`)
    }
  }

  /**
   * Get the stream endpoint URL.
   *
   * @description the URL returned from this endpoint can be attached to an
   * img tag's src attribute. The device's live stream will be started and
   * begin streaming to the img tag as a
   * {@link https://en.wikipedia.org/wiki/Motion_JPEG|Motion JPEG} stream.
   * If the src is changed, the img removed from the DOM, or client disconnected
   * for any reason, the live feed will automatically be stopped.
   *
   * @method
   * @param {StreamOptions} [streamOpt] - Additional options for streaming.
   * @returns {string} stream endpoint URL
   * @example
   * const img = document.createElement('img')
   * img.src = device.streamUrl()
   * document.body.appendChild(img)
   */
  streamUrl (streamOpt = {}) {
    this.can('start_feed')
    this.can('stop_feed')

    // If the URL returned from this method is set as an `img` element's `src`
    // the URL must change in order for a browser to reload the stream. So here,
    // we've set a URL parameter that is different every invocation. This will
    // cause the browser to always reload a stream. This "x" parameter has no
    // meaning, its essentially a cache-breaker for the DOM.
    const x = performance.now()

    const mergedOpts = Object.assign({ x }, DEFAULT_STREAM_OPT, streamOpt)
    const params = urlParamsToString(mergedOpts)
    return `${this.baseUrl}/plugin/${this.plugin.id}/device/${this.id}/stream?${params}`
  }

  /**
   * Take full capture from the specified device and return an
   * {@link https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL_static|ObjectURL}
   * that can be set directly on an img tag's src attribute. Note that URLs
   * created by this method should be
   * {@link https://developer.mozilla.org/en-US/docs/Web/API/URL/revokeObjectURL_static|revoked}
   * when they are no longer needed.
   * (Async/Await version)
   *
   * @method
   * @async
   * @param {CaptureOptions} [captureOpt] - Additional options for capturing a
   * frame.
   * @returns {string}
   * @example
   * // Load the blob into FileReader and append to the document's body
   * const blob = await device.captureAsBlob()
   * const reader = new FileReader()
   * reader.onload = event => {
   *   const img = document.createElement('img')
   *   img.src = event.target.result
   *   document.body.appendChild(img)
   * }
   * reader.readAsDataURL(blob)
   */
  async captureAsObjectURL (captureOpt = {}) {
    this.can('capture')
    const captureParams = Object.assign({}, DEFAULT_CAPTURE_OPT, captureOpt)

    // Method ignores kind, always returns an object URL
    delete captureParams.kind

    const params = urlParamsToString(captureParams)
    const url = `${this.baseUrl}/plugin/${this.plugin.id}/device/${this.id}/capture?${params}`
    const response = await fetch(url, POST)
    return await response.blob()
  }

  /**
   * Take full capture from the specified device and return a
   * {@link https://developer.mozilla.org/en-US/docs/Web/API/Blob|Blob} that can
   * be used with a {@link https://developer.mozilla.org/en-US/docs/Web/API/FileReader|FileReader}
   * (Async/Await version)
   *
   * @method
   * @async
   * @param {CaptureOptions} [captureOpt] - Additional options for capturing a
   * frame.
   * @returns {object} {@link https://developer.mozilla.org/en-US/docs/Web/API/Blob|Blob}
   * @example
   * // Load the blob into FileReader and append to the document's body
   * const blob = await device.captureAsBlob()
   * const reader = new FileReader()
   * reader.onload = event => {
   *   const img = document.createElement('img')
   *   img.src = event.target.result
   *   document.body.appendChild(img)
   * }
   * reader.readAsDataURL(blob)
   */
  async captureAsBlob (captureOpt = {}) {
    this.can('capture')
    const captureParams = Object.assign({}, DEFAULT_CAPTURE_OPT, captureOpt)

    // Method ignores kind, always returns binary data (blob)
    delete captureParams.kind

    const params = urlParamsToString(captureParams)
    const url = `${this.baseUrl}/plugin/${this.plugin.id}/device/${this.id}/capture?${params}`
    const response = await fetch(url, POST)
    return await response.blob()
  }

  /**
   * Take full capture from the specified device and return a
   * base64 encoded string of the image that can be used with a
   * {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URLs|Data URL}
   * (Async/Await version)
   *
   * @method
   * @async
   * @param {CaptureOptions} [captureOpt] - Additional options for capturing a
   * frame.
   * @returns {string} Base64 Encoded image
   * @example
   * // Add the base64 string as a Data URL to an img tag and append to
   * // the document's body
   * const base64 = await device.captureAsBase64()
   * const img = document.createElement('img')
   * img.src = 'data:image/jpeg;base64,' + base64
   * document.body.appendChild(img)
   */
  async captureAsBase64 (captureOpt = {}) {
    this.can('capture')
    const captureParams = Object.assign({}, DEFAULT_CAPTURE_OPT, captureOpt)

    // Method ignores kind, always returns base64 data
    delete captureParams.kind
    captureParams.base64 = true

    const params = urlParamsToString(captureParams)
    const url = `${this.baseUrl}/plugin/${this.plugin.id}/device/${this.id}/capture?${params}`
    const response = await fetch(url, POST)
    await checkResponse(response)
    const { image } = await response.json()
    return image
  }

  /**
   * Get a base64 encoded frame from a device's live feed.
   * @method
   *
   * @description This method will startup the live
   * feed if necessary and wait for it to initialize before returning a frame.
   * Subsequent calls will return the next available frame.
   * You must manually stop the live feed if you are using this method.
   *
   * The frame returned will be a base64 encoded string of the image that can
   * be used with a
   * {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URLs|Data URL}
   *
   * If implementing a real-time preview, it is highly recommended to use the
   * stream endpoint which will stream a Motion JPEG.
   *
   * @see {@link streamUrl}
   * @see {@link stopFeed}
   *
   * @async
   * @param {number} [millis] - Milliseconds to wait if feed is not ready
   * @param {CaptureOptions} [captureOpt] - Additional options for capturing a
   * frame.
   * @returns {string} Base64 Encoded image
   * @example
   * // Add the base64 string as a Data URL to an img tag and append to
   * // the document's body
   * const base64 = await device.frameAsBase64()
   * const img = document.createElement('img')
   * img.src = 'data:image/jpeg;base64,' + base64
   * document.body.appendChild(img)
   */
  async frameAsBase64 (captureOpt = {}, millis = 500) {
    this.can('start_feed')
    const captureParams = Object.assign({}, DEFAULT_CAPTURE_OPT, captureOpt)

    // Method ignores kind, always returns base64 data
    delete captureParams.kind
    captureParams.base64 = true

    const params = urlParamsToString(captureParams)
    const url = `${this.baseUrl}/plugin/${this.plugin.id}/device/${this.id}/livefeed?${params}`
    const response = await fetch(url)
    const result = await response.json()
    if (result.error && (result.code === 425 || result.code === 400)) {
      await timeout(millis)
      return await this.frameAsBase64(captureOpt, millis)
    } else {
      return result.image
    }
  }

  /**
   * Get a binary frame from a device's live feed. (Async/await version)
   * @method
   *
   * @description This method will startup the live
   * feed if necessary and wait for it to initialize before returning a frame.
   * Subsequent calls will return the next available frame.
   * You must manually stop the live feed if you are using this method.
   *
   * The frame returned will be a
   * {@link https://developer.mozilla.org/en-US/docs/Web/API/Blob|Blob} that can
   * be used with a {@link https://developer.mozilla.org/en-US/docs/Web/API/FileReader|FileReader}
   *
   * If implementing a real-time preview, it is highly recommended to use the
   * stream endpoint which will stream a Motion JPEG.
   *
   * @see {@link streamUrl}
   * @see {@link stopFeed}
   *
   * @async
   * @param {CaptureOptions} [captureOpt] - Additional options for capturing a
   * frame.
   * @param {number} [millis] - Milliseconds to wait if feed is not ready
   * @returns {object} {@link https://developer.mozilla.org/en-US/docs/Web/API/Blob|Blob}
   * @example
   * // Load the blob into FileReader and append to the document's body
   * const blob = await device.frameAsBlob()
   * const reader = new FileReader()
   * reader.onload = event => {
   *   const img = document.createElement('img')
   *   img.src = event.target.result
   *   document.body.appendChild(img)
   * }
   * reader.readAsDataURL(blob)
   */
  async frameAsBlob (captureOpt = {}, millis = 500) {
    this.can('start_feed')
    const captureParams = Object.assign({}, DEFAULT_CAPTURE_OPT, captureOpt)

    // Method ignores kind, always returns binary data (blob)
    delete captureParams.kind

    const params = urlParamsToString(captureParams)
    const url = `${this.baseUrl}/plugin/${this.plugin.id}/device/${this.id}/livefeed?${params}`
    const result = await this.frameAsBase64(captureOpt, millis)
    if (typeof result === 'string') {
      timeout(millis)
      const response = await fetch(url)
      return await response.blob()
    }
    throw new Error(result || 'Failed to get frame')
  }

  /**
   * Stop the live feed if it is running.
   * (Async/Await version)
   *
   * @method
   * @async
   * @returns {object} Status of the stop operation
   * @example
   * // Plugin is now running it's live feed in a background thread
   * const blob = await device.frameAsBlob()
   * // Live feed is now stopping
   * console.log(await device.stopFeed())
   */
  async stopFeed () {
    this.can('stop_feed')
    const url = `${this.baseUrl}/plugin/${this.plugin.id}/device/${this.id}/livefeed`
    const response = await fetch(url, DELETE)
    return await response.json()
  }

  /**
   * Clear a device's display.
   * (Async/Await version)
   *
   * @method
   * @async
   * @param {boolean} [backlight] If provided and set to true, will enable
   * backlight. If false, it will be disabled. If unspecified no action will be
   * taken on the backlight.
   * @returns {object} Status of the clear operation
   * @example
   * console.log(await await device.clear())
   */
  async clear (backlight) {
    this.canDoOne(['draw', 'clear'])
    let url = `${this.baseUrl}/plugin/${this.plugin.id}/device/${this.id}/display`
    if (typeof backlight === 'boolean') {
      url += `?backlight=${backlight}`
    }
    const response = await fetch(url, DELETE)
    await checkResponse(response)
    return await response.json()
  }

  /**
   * Render an Array of Objects on the display and wait for user input.
   *
   * @description If any Objects are Buttons, the method will wait for one
   * to be clicked and will then return results. The format of the Array of
   * Objects, the response structure, and the scene options are plugin specific.
   *
   * @param {Object[]} objects An array of drawable objects to display.
   * @param {Object} [sceneOpt] Options for configuring the scene.
   * @param {boolean} [sceneOpt.clear] If `true`, the display will be cleared
   * before adding the Objects.
   * @param {Object} [sceneOpt.backlight] If `true`, the backlight will be
   * enabled before adding the Objects.
   *
   * @param {AbortSignal} [signal] - An {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal|AbortSignal}
   * obtained from an {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortController|AbortController}
   * allowing the request to be aborted. If the request is aborted or the HTTP
   * connection is closed for any reason before a user has interacted with the
   * display, the device state will be reset and the screen will be cleared.
   *
   * @async
   * @method
   */
  async displayObjects (objects, sceneOpt = {}, signal) {
    this.can('draw')
    const url = `${this.baseUrl}/plugin/${this.plugin.id}/device/${this.id}/display`

    const asyncOperations = objects.map(async obj => {
      if (typeof obj.data === 'function') {
        await obj.data()
      }
    })

    // Wait for all async operations to complete
    await Promise.all(asyncOperations)

    sceneOpt.objects = objects

    const options = {
      method: 'POST',
      mode: 'cors',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(sceneOpt)
    }

    if (signal instanceof AbortSignal) {
      options.signal = signal
    }

    const response = await fetch(url, options)
    await checkResponse(response)
    return await response.json()
  }

  /**
   * Get detailed information about a device
   *
   * @description Returns detailed information about a device.
   *
   * @async
   * @method
   * @returns {object} Device info
   * @example
   * const info = await device.info()
   */
  async info () {
    this.can('device_info')
    const url = `${this.baseUrl}/plugin/${this.plugin.id}/device/${this.id}/info`
    const response = await fetch(url)
    return await response.json()
  }

  /**
   * Cancel a pending device operation
   *
   * @description Will attempt to cancel any active long-running operation
   *
   * @param {Number?} [timeout] If provided, will attempt to
   * wait at least this long (in seconds) for the operation to be cancelled
   * before returning a response. By default this endpoint will request a
   * cancellation and return immediately, however if you'd like to block, waiting
   * for the device to be ready before attempting another operation you may supply
   * this timeout.
   *
   * @async
   * @method
   * @returns {object} Cancellation status
   * @example
   * const {status} = await device.cancel()
   */
  async cancel (timeout) {
    this.can('cancel')
    let url = `${this.baseUrl}/plugin/${this.plugin.id}/device/${this.id}/cancel`
    if (typeof timeout === 'number') {
      this.can('ready')
      url += `?timeout=${timeout}`
    }
    const options = {
      method: 'POST',
      mode: 'cors',
      headers: {
        'Content-Type': 'application/json'
      }
    }
    const response = await fetch(url, options)
    await checkResponse(response)
    return await response.json()
  }

  /**
   * Restart a device or reset its state.
   *
   * @description Used to restart a device or reset its state
   *
   * @param {Number?} [depth=0] Some devices have several abstractions layers
   * that can be reset separately. For example, the entire device might be
   * rebooted or an outstanding operation could be cancelled.
   *
   * @async
   * @method
   * @returns {object} Reset status
   * @example
   * const {status} = await device.reset()
   */
  async reset (depth = 0) {
    this.can('reset')
    let url = `${this.baseUrl}/plugin/${this.plugin.id}/device/${this.id}/reset`
    if (typeof depth === 'number') {
      this.can('reset')
      url += `?depth=${depth}`
    }
    const options = {
      method: 'POST',
      mode: 'cors',
      headers: {
        'Content-Type': 'application/json'
      }
    }
    const response = await fetch(url, options)
    await checkResponse(response)
    return await response.json()
  }
}

export default Device