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