diff --git a/docs/5_remote_control/http_remote_control.md b/docs/5_remote_control/http_remote_control.md index c085ac52c9..54f801145a 100644 --- a/docs/5_remote_control/http_remote_control.md +++ b/docs/5_remote_control/http_remote_control.md @@ -2,6 +2,80 @@ Remote triggering can be done by sending `HTTP` Requests to the same IP and port **Commands** +This API tries to follow REST principles, and the convention that a `POST` request will modify a value, and a `GET` request will retrieve values. + +- Press and release a button (run both down and up actions) + Method: POST + Path: `/api/location////press` +- Press the button (run down actions and hold) + Method: POST + Path: `/api/location////down` +- Release the button (run up actions) + Method: POST + Path: `/api/location////up` +- Trigger a left rotation of the button/encoder + Method: POST + Path: `/api/location////rotate-left` +- Trigger a right rotation of the button/encoder + Method: POST + Path: `/api/location////rotate-right` +- Set the current step of a button/encoder + Method: POST + Path: `/api/location////step` + +- Change background color of button + Method: POST + Path: `/api/location////style?bgcolor=` +- Change background color of button + Method: POST + Path: `/api/location////style` + Body: `{ "bgcolor": "" }` OR `{ "bgcolor": "rgb(,,)" }` +- Change text color of button + Method: POST + Path: `/api/location////style?color=` +- Change text color of button + Method: POST + Path: `/api/location////style` + Body: `{ "color": "" }` OR `{ "color": "rgb(,,)" }` +- Change text of button + Method: POST + Path: `/api/location////style?text=` +- Change text color of button + Method: POST + Path: `/api/location////style` + Body: `{ "text": "" }` + +- Change custom variable value + Method: POST + Path: `/api/custom-variable//value?value=` +- Change custom variable value + Method: POST + Path: `/api/custom-variable//value` + Body: `` +- Get custom variable value + Method: GET + Path: `/api/custom-variable//value` +- Rescan for USB surfaces + Method: POST + Path: `/surfaces/rescan` + +**Examples** +Press page 1 row 0 column 2: +POST `/api/location/1/0/2/press` + +Change the text of row 0 column 4 on page 2 to TEST: +POST `/api/location/1/0/4/style?text=TEST` + +Change the text of row 1, column 4 on page 2 to TEST, background color to #ffffff, text color to #000000 and font size to 28px: +POST `/api/location/2/1/4/style` with body `{ "text": "TEST", "bgcolor": "#ffffff", "color": "#000000", "size": 28 }` + +Change custom variable "cue" to value "intro": +POST `/api/custom-variable/cue/value?value=intro` + +**Deprecated Commands** + +The following commands are deprecated and have replacements listed above. They will be removed in a future version of Companion. + - `/press/bank//` _Press and release a button (run both down and up actions)_ - `/press/bank///down` @@ -20,17 +94,3 @@ Remote triggering can be done by sending `HTTP` Requests to the same IP and port _Change custom variable value_ - `/rescan` _Make Companion rescan for newly attached USB surfaces_ - - -**Examples** -Press page 1 bank 2: -`/press/bank/1/2` - -Change the text of button 4 on page 2 to TEST: -`/style/bank/2/4/?text=TEST` - -Change the text of button 4 on page 2 to TEST, background color to #ffffff, text color to #000000 and font size to 28px: -`/style/bank/2/4/?text=TEST&bgcolor=%23ffffff&color=%23000000&size=28px` - -Change custom variable "cue" to value "intro": -`/set/custom-variable/cue?value=intro` diff --git a/docs/5_remote_control/osc_control.md b/docs/5_remote_control/osc_control.md index e03fc03260..0f8dd0cfb9 100644 --- a/docs/5_remote_control/osc_control.md +++ b/docs/5_remote_control/osc_control.md @@ -1,34 +1,67 @@ -Remote triggering can be done by sending OSC commands to port `12321`. +Remote triggering can be done by sending OSC commands to port `12321` (the port number is configurable). **Commands** -- `/press/bank/ ` +- `/location////press` _Press and release a button (run both down and up actions)_ -- `/press/bank/ <1>` +- `/location////down` _Press the button (run down actions and hold)_ -- `/press/bank/ <0>` +- `/location////up` _Release the button (run up actions)_ -- `/style/bgcolor/ ` +- `/location////rotate-left` + _Trigger a left rotation of the button/encoder_ +- `/location////rotate-right` + _Trigger a right rotation of the button/encoder_ +- `/location////step` + _Set the current step of a button/encoder_ + +- `/location////style/bgcolor ` _Change background color of button_ -- `/style/color/ ` +- `/location////style/bgcolor ` + _Change background color of button_ +- `/location////style/color ` _Change color of text on button_ -- `/style/text/ ` +- `/location////style/color ` + _Change color of text on button_ +- `/location////style/text ` _Change text on a button_ + - `/custom-variable//value ` _Change custom variable value_ -- `/rescan 1` - _Make Companion rescan for newly attached USB surfaces_ +- `/surfaces/rescan` + _Rescan for USB surfaces_ **Examples** -Press button 5 on page 1 down and hold -`/press/bank/1/5 1` +Press row 0, column 5 on page 1 down and hold +`/location/1/0/5/press` -Change button background color of button 5 on page 1 to red -`/style/bgcolor/1/5 255 0 0` +Change button background color of row 0, column 5 on page 1 to red +`/location/1/0/5/style/bgcolor 255 0 0` +`/location/1/0/5/style/bgcolor rgb(255,0,0)` +`/location/1/0/5/style/bgcolor #ff0000` -Change the text of button 5 on page 1 to ONLINE -`/style/text/1/5 ONLINE` +Change the text of row 0, column 5 on page 1 to ONLINE +`/location/1/0/5/style/text ONLINE` Change custom variable "cue" to value "intro": -`/custom-variable/cue intro` +`/custom-variable/cue/value intro` + +**Deprecated Commands** + +The following commands are deprecated and have replacements listed above. They will be removed in a future version of Companion. + +- `/press/bank/ ` + _Press and release a button (run both down and up actions)_ +- `/press/bank/ <1>` + _Press the button (run down actions and hold)_ +- `/press/bank/ <0>` + _Release the button (run up actions)_ +- `/style/bgcolor/ ` + _Change background color of button_ +- `/style/color/ ` + _Change color of text on button_ +- `/style/text/ ` + _Change text on a button_ +- `/rescan 1` + _Make Companion rescan for newly attached USB surfaces_ diff --git a/docs/5_remote_control/tcp_udp.md b/docs/5_remote_control/tcp_udp.md index f183d3bd63..2cb8585419 100644 --- a/docs/5_remote_control/tcp_udp.md +++ b/docs/5_remote_control/tcp_udp.md @@ -2,6 +2,54 @@ Remote triggering can be done by sending TCP (port `51234`) or UDP (port `51235` **Commands** +- `SURFACE PAGE-SET ` + _Set a surface to a specific page_ +- `SURFACE PAGE-UP` + _Page up on a specific surface_ +- `SURFACE PAGE-DOWN` + _Page down on a specific surface_ + +- `LOCATION // PRESS` + _Press and release a button (run both down and up actions)_ +- `LOCATION // DOWN` + _Press the button (run down actions)_ +- `LOCATION // UP` + _Release the button (run up actions)_ +- `LOCATION // ROTATE-LEFT` + _Trigger a left rotation of the button/encoder_ +- `LOCATION // ROTATE-RIGHT` + _Trigger a right rotation of the button/encoder_ +- `LOCATION // SET-STEP ` + _Set the current step of a button/encoder_ + +- `LOCATION // STYLE TEXT ` + _Change text on a button_ +- `LOCATION // STYLE COLOR ` + _Change text color on a button (#000000)_ +- `LOCATION // STYLE BGCOLOR ` + _Change background color on a button (#000000)_ + +- `CUSTOM-VARIABLE SET-VALUE ` + _Change custom variable value_ +- `SURFACES RESCAN` + _Make Companion rescan for USB surfaces_ + + +**Examples** +Set the emulator surface to page 23: +`SURFACE emulator PAGE-SET 23` + +Press page 1 row 2 column 3: +`LOCATION 1/2/3 PRESS` + +Change custom variable "cue" to value "intro": +`CUSTOM-VARIABLE cue SET-VALUE intro` + + +**Deprecated Commands** + +The following commands are deprecated and have replacements listed above. They will be removed in a future version of Companion. + - `PAGE-SET ` _Make device go to a specific page_ - `PAGE-UP ` @@ -20,18 +68,5 @@ Remote triggering can be done by sending TCP (port `51234`) or UDP (port `51235` _Change text color on a button (#000000)_ - `STYLE BANK BGCOLOR ` _Change background color on a button (#000000)_ -- `CUSTOM-VARIABLE SET-VALUE ` - _Change custom variable value_ - `RESCAN` _Make Companion rescan for newly attached USB surfaces_ - - -**Examples** -Set the emulator surface to page 23: -`PAGE-SET 23 emulator` - -Press page 1 bank 2: -`BANK-PRESS 1 2` - -Change custom variable "cue" to value "intro": -`CUSTOM-VARIABLE cue SET-VALUE intro` diff --git a/lib/@types/osc.d.ts b/lib/@types/osc.d.ts index c270ecf402..64b38ba751 100644 --- a/lib/@types/osc.d.ts +++ b/lib/@types/osc.d.ts @@ -47,6 +47,11 @@ declare module 'osc' { args: Argument | Array | MetaArgument | Array } + export interface OscReceivedMessage { + address: string + args: Array + } + // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface OscBundle {} @@ -59,7 +64,7 @@ declare module 'osc' { export interface PortEvents { ready: () => void - message: (message: OscMessage, timeTag: number | undefined, info: SenderInfo) => void + message: (message: OscReceivedMessage, timeTag: number | undefined, info: SenderInfo) => void bundle: (bundle: OscBundle, timeTag: number, info: SenderInfo) => void osc: (packet: OscBundle | OscMessage, info: SenderInfo) => void raw: (data: Uint8Array, info: SenderInfo) => void diff --git a/lib/Data/Model/UserConfigModel.ts b/lib/Data/Model/UserConfigModel.ts index e19c0cde27..d92442ce2a 100644 --- a/lib/Data/Model/UserConfigModel.ts +++ b/lib/Data/Model/UserConfigModel.ts @@ -19,14 +19,20 @@ export interface UserConfigModel { pin: string pin_timeout: number + http_api_enabled: boolean + http_legacy_api_enabled: boolean + tcp_enabled: boolean tcp_listen_port: number + tcp_legacy_api_enabled: boolean udp_enabled: boolean udp_listen_port: number + udp_legacy_api_enabled: boolean osc_enabled: boolean osc_listen_port: number + osc_legacy_api_enabled: boolean rosstalk_enabled: boolean diff --git a/lib/Data/UserConfig.js b/lib/Data/UserConfig.js index f24837fe53..e029913b75 100644 --- a/lib/Data/UserConfig.js +++ b/lib/Data/UserConfig.js @@ -49,14 +49,20 @@ class DataUserConfig extends CoreBase { pin: '', pin_timeout: 0, + http_api_enabled: true, + http_legacy_api_enabled: false, + tcp_enabled: false, tcp_listen_port: 16759, + tcp_legacy_api_enabled: false, udp_enabled: false, udp_listen_port: 16759, + udp_legacy_api_enabled: false, osc_enabled: false, osc_listen_port: 12321, + osc_legacy_api_enabled: false, rosstalk_enabled: false, @@ -117,7 +123,7 @@ class DataUserConfig extends CoreBase { this.data = this.db.getKey('userconfig', cloneDeep(DataUserConfig.Defaults)) - this.checkV2InPlaceUpgrade() + this.#populateMissingForExistingDb() let save = false // copy default values. this will set newly added defaults too @@ -157,7 +163,7 @@ class DataUserConfig extends CoreBase { * For an existing DB we need to check if some new settings are present * @access protected */ - checkV2InPlaceUpgrade() { + #populateMissingForExistingDb() { if (!this.db.getIsFirstRun()) { // This is an existing db, so setup the ports to match how it used to be /** @type {Partial} */ @@ -200,6 +206,27 @@ class DataUserConfig extends CoreBase { if (this.data['usb_hotplug'] === undefined) { this.data['usb_hotplug'] = false } + + // Enable the legacy OSC api if OSC is enabled + if (this.data.osc_enabled && this.data.osc_legacy_api_enabled === undefined) { + this.data.osc_legacy_api_enabled = true + } + + // Enable the legacy TCP api if TCP is enabled + if (this.data.tcp_enabled && this.data.tcp_legacy_api_enabled === undefined) { + this.data.tcp_legacy_api_enabled = true + } + + // Enable the legacy UDP api if UDP is enabled + if (this.data.udp_enabled && this.data.udp_legacy_api_enabled === undefined) { + this.data.udp_legacy_api_enabled = true + } + + // Enable the http api (both modern and legacy) + if (this.data.http_api_enabled === undefined) { + this.data.http_api_enabled = true + this.data.http_legacy_api_enabled = true + } } } diff --git a/lib/Instance/CustomVariable.js b/lib/Instance/CustomVariable.js index 1c956a6091..602ab91f70 100644 --- a/lib/Instance/CustomVariable.js +++ b/lib/Instance/CustomVariable.js @@ -338,6 +338,16 @@ export default class InstanceCustomVariable { } } + /** + * Get the value of a custom variable + * @param {string} name + * @returns {CompanionVariableValue | undefined} + */ + getValue(name) { + const fullname = `${custom_variable_prefix}${name}` + return this.#base.getVariableValue('internal', fullname) + } + /** * Set the value of a custom variable * @param {string} name diff --git a/lib/Resources/Util.js b/lib/Resources/Util.js index ef00c1526e..3d1fe9d83c 100644 --- a/lib/Resources/Util.js +++ b/lib/Resources/Util.js @@ -100,6 +100,26 @@ export const parseColor = (color, skipValidation = false) => { return 'rgba(0, 0, 0, 0)' } +/** + * Parse a css color string to a number + * @param {any} color + * @returns {number | false} + */ +export const parseColorToNumber = (color) => { + if (typeof color === 'string') { + const newColor = colord(color) + if (newColor.isValid()) { + return rgb(newColor.rgba.r, newColor.rgba.g, newColor.rgba.b) + } else { + return false + } + } + if (typeof color === 'number') { + return color + } + return false +} + /** * @param {number} milliseconds */ diff --git a/lib/Service/Api.js b/lib/Service/Api.js deleted file mode 100644 index d9220d86c0..0000000000 --- a/lib/Service/Api.js +++ /dev/null @@ -1,212 +0,0 @@ -import CoreBase from '../Core/Base.js' -import RegexRouter from './RegexRouter.js' - -/** - * Common API command processing for {@link ServiceTcp} and {@link ServiceUdp}. - * - * @extends CoreBase - * @author Håkon Nessjøen - * @author Keith Rocheck - * @author William Viker - * @author Julian Waller - * @since 1.3.0 - * @copyright 2022 Bitfocus AS - * @license - * This program is free software. - * You should have received a copy of the MIT licence as well as the Bitfocus - * Individual Contributor License Agreement for Companion along with - * this program. - * - * You can be released from the requirements of the license by purchasing - * a commercial license. Buying such a license is mandatory as soon as you - * develop commercial activities involving the Companion software without - * disclosing the source code of your own applications. - */ -class ServiceApi extends CoreBase { - /** - * Message router - * @type {RegexRouter} - * @access private - */ - #router - - /** - * @param {import('../Registry.js').default} registry - the core registry - */ - constructor(registry) { - super(registry, 'api', 'Service/Api') - - this.#router = new RegexRouter(() => { - throw new ApiMessageError('Syntax error') - }) - this.#setupRoutes() - } - - #setupRoutes() { - this.#router.addPath('page-set :page(\\d+) :surfaceId', (match) => { - const page = parseInt(match.page) - const surfaceId = match.surfaceId - - this.surfaces.devicePageSet(surfaceId, page) - - return `If ${surfaceId} is connected` - }) - - this.#router.addPath('page-up :surfaceId', (match) => { - const surfaceId = match.surfaceId - - this.surfaces.devicePageUp(surfaceId) - - return `If ${surfaceId} is connected` - }) - - this.#router.addPath('page-down :surfaceId', (match) => { - const surfaceId = match.surfaceId - - this.surfaces.devicePageDown(surfaceId) - - return `If ${surfaceId} is connected` - }) - - this.#router.addPath('bank-press :page(\\d+) :bank(\\d+)', (match) => { - const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) - if (!controlId) throw new ApiMessageError('Page/bank out of range') - - this.logger.info(`Got bank-press (trigger) ${controlId}`) - - if (!this.controls.pressControl(controlId, true, undefined)) { - throw new ApiMessageError('Control does not support presses') - } - - setTimeout(() => { - this.logger.info(`Auto releasing bank-press ${controlId}`) - this.controls.pressControl(controlId, false, undefined) - }, 20) - }) - - this.#router.addPath('bank-down :page(\\d+) :bank(\\d+)', (match) => { - const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) - if (!controlId) throw new ApiMessageError('Page/bank out of range') - - this.logger.info(`Got bank-down (trigger) ${controlId}`) - - if (!this.controls.pressControl(controlId, true, undefined)) { - throw new ApiMessageError('Control does not support presses') - } - }) - - this.#router.addPath('bank-up :page(\\d+) :bank(\\d+)', (match) => { - const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) - if (!controlId) throw new ApiMessageError('Page/bank out of range') - - this.logger.info(`Got bank-up (trigger) ${controlId}`) - - if (!this.controls.pressControl(controlId, false, undefined)) { - throw new ApiMessageError('Control does not support presses') - } - }) - - this.#router.addPath('bank-step :page(\\d+) :bank(\\d+) :step(\\d+)', (match) => { - const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) - if (!controlId) throw new ApiMessageError('Page/bank out of range') - - const step = Number(match.step) - - this.logger.info(`Got bank-step (trigger) ${controlId} ${step}`) - - if (isNaN(step) || step <= 0) throw new ApiMessageError('Step out of range') - - const control = this.controls.getControl(controlId) - if (!control || !control.supportsSteps) throw new ApiMessageError('Invalid control') - - if (!control.stepMakeCurrent(step)) throw new ApiMessageError('Step out of range') - }) - - this.#router.addPath('style bank :page(\\d+) :bank(\\d+) text{ :text}?', (match) => { - const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) - if (!controlId) throw new ApiMessageError('Page/bank out of range') - - const control = controlId && this.controls.getControl(controlId) - - if (control && control.supportsStyle) { - const text = match.text || '' - - control.styleSetFields({ text: text }) - } else { - throw new ApiMessageError('Page/bank out of range') - } - }) - - this.#router.addPath('style bank :page(\\d+) :bank(\\d+) bgcolor #:color([a-f\\d]+)', (match) => { - const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) - if (!controlId) throw new ApiMessageError('Page/bank out of range') - - const color = parseInt(match.color, 16) - if (isNaN(color)) throw new ApiMessageError('Invalid color') - - const control = controlId && this.controls.getControl(controlId) - - if (control && control.supportsStyle) { - control.styleSetFields({ bgcolor: color }) - } else { - throw new ApiMessageError('Page/bank out of range') - } - }) - - this.#router.addPath('style bank :page(\\d+) :bank(\\d+) color #:color([a-f\\d]+)', (match) => { - const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) - if (!controlId) throw new ApiMessageError('Page/bank out of range') - - const color = parseInt(match.color, 16) - if (isNaN(color)) throw new ApiMessageError('Invalid color') - - const control = controlId && this.controls.getControl(controlId) - - if (control && control.supportsStyle) { - control.styleSetFields({ color: color }) - } else { - throw new ApiMessageError('Page/bank out of range') - } - }) - - this.#router.addPath('rescan', async () => { - this.logger.debug('Rescanning USB') - - try { - await this.surfaces.triggerRefreshDevices() - } catch (e) { - throw new ApiMessageError('Scan failed') - } - }) - - this.#router.addPath('custom-variable :name set-value :value(.*)', async (match) => { - const result = this.instance.variable.custom.setValue(match.name, match.value) - if (result) { - throw new ApiMessageError(result) - } - }) - } - - /** - * Fire an API command from a raw TCP/UDP command - * @param {string} data - the raw command - * @returns {Promise} - */ - async parseApiCommand(data) { - data = data.trim() - this.logger.silly(`API parsing command: ${data}`) - - return this.#router.processMessage(data) - } -} - -export class ApiMessageError extends Error { - /** - * @param {string} message - */ - constructor(message) { - super(message) - } -} - -export default ServiceApi diff --git a/lib/Service/Controller.js b/lib/Service/Controller.js index 960c368e12..a44605c510 100644 --- a/lib/Service/Controller.js +++ b/lib/Service/Controller.js @@ -1,8 +1,8 @@ -import ServiceApi from './Api.js' import ServiceArtnet from './Artnet.js' import ServiceBonjourDiscovery from './BonjourDiscovery.js' import ServiceElgatoPlugin from './ElgatoPlugin.js' import ServiceEmberPlus from './EmberPlus.js' +import { ServiceHttpApi } from './HttpApi.js' import ServiceHttps from './Https.js' import ServiceOscListener from './OscListener.js' import ServiceOscSender from './OscSender.js' @@ -37,13 +37,14 @@ class ServiceController { * @param {import('../Registry.js').default} registry - the application core */ constructor(registry) { + this.httpApi = new ServiceHttpApi(registry, registry.ui.express.legacyApiRouter) + this.httpApi.bindToApp(registry.ui.express.app) // @ts-ignore this.https = new ServiceHttps(registry, registry.ui.express, registry.io) this.oscSender = new ServiceOscSender(registry) this.oscListener = new ServiceOscListener(registry) - this.api = new ServiceApi(registry) - this.tcp = new ServiceTcp(registry, this.api) - this.udp = new ServiceUdp(registry, this.api) + this.tcp = new ServiceTcp(registry) + this.udp = new ServiceUdp(registry) this.emberplus = new ServiceEmberPlus(registry) this.artnet = new ServiceArtnet(registry) this.rosstalk = new ServiceRosstalk(registry) diff --git a/lib/Service/EmberPlus.js b/lib/Service/EmberPlus.js index d8ec8d0c10..7487f3e3c4 100644 --- a/lib/Service/EmberPlus.js +++ b/lib/Service/EmberPlus.js @@ -1,13 +1,32 @@ import { EmberServer, Model as EmberModel } from 'emberplus-connection' import { getPath } from 'emberplus-connection/dist/Ember/Lib/util.js' import ServiceBase from './Base.js' -import { xyToOldBankIndex } from '../Shared/ControlId.js' -import { pad } from '../Resources/Util.js' +import { formatLocation, xyToOldBankIndex } from '../Shared/ControlId.js' +import { pad, parseColorToNumber } from '../Resources/Util.js' -const NODE_STATE = 0 -const NODE_TEXT = 1 -const NODE_TEXT_COLOR = 2 -const NODE_BG_COLOR = 3 +// const LOCATION_NODE_CONTROLID = 0 +const LOCATION_NODE_PRESSED = 1 +const LOCATION_NODE_TEXT = 2 +const LOCATION_NODE_TEXT_COLOR = 3 +const LOCATION_NODE_BG_COLOR = 4 + +const LEGACY_NODE_STATE = 0 +const LEGACY_NODE_TEXT = 1 +const LEGACY_NODE_TEXT_COLOR = 2 +const LEGACY_NODE_BG_COLOR = 3 + +/** + * Generate ember+ path + * @param {import('../Data/Model/ExportModel.js').ExportGridSize} gridSize + * @param {import('../Resources/Util.js').ControlLocation} location + * @param {number} node + * @returns {string} + */ +function buildPathForLocation(gridSize, location, node) { + const row = location.row - gridSize.minRow + const column = location.column - gridSize.minColumn + return `0.2.${location.pageNumber}.${row}.${column}.${node}` +} /** * Generate ember+ path @@ -20,12 +39,14 @@ function buildPathForButton(page, bank, node) { return `0.1.${page}.${bank}.${node}` } /** - * Convert numeric color to hex - * @param {number} color + * Convert internal color to hex + * @param {any} color * @returns {string} */ function formatColorAsHex(color) { - return `#${pad(Number(color).toString(16).slice(-6), '0', 6)}` + const newColor = parseColorToNumber(color) + if (newColor === false) return '#000000' + return `#${pad(newColor.toString(16).slice(-6), '0', 6)}` } /** * Parse hex color as number @@ -106,69 +127,67 @@ class ServiceEmberPlus extends ServiceBase { * @access private */ #getPagesTree() { - let pages = this.page.getAll(true) - /** @type {Record>} */ let output = {} - for (let page = 1; page <= 99; page++) { + for (let pageNumber = 1; pageNumber <= 99; pageNumber++) { /** @type {Record>} */ const children = {} for (let bank = 1; bank <= 32; bank++) { - const controlId = this.page.getControlIdAtOldBankIndex(page, bank) - if (!controlId) continue - const control = this.controls.getControl(controlId) + const controlId = this.page.getControlIdAtOldBankIndex(pageNumber, bank) + const control = controlId ? this.controls.getControl(controlId) : undefined - /** @type {any} */ - const drawStyle = control?.getDrawStyle() || {} + /** @type {import('../Data/Model/StyleModel.js').DrawStyleModel | null} */ + let drawStyle = control?.getDrawStyle() || null + if (drawStyle?.style !== 'button') drawStyle = null children[bank] = new EmberModel.NumberedTreeNodeImpl( bank, - new EmberModel.EmberNodeImpl(`Button ${page}.${bank}`), + new EmberModel.EmberNodeImpl(`Button ${pageNumber}.${bank}`), { - [NODE_STATE]: new EmberModel.NumberedTreeNodeImpl( - NODE_STATE, + [LEGACY_NODE_STATE]: new EmberModel.NumberedTreeNodeImpl( + LEGACY_NODE_STATE, new EmberModel.ParameterImpl( EmberModel.ParameterType.Boolean, 'State', undefined, - this.#pushedButtons.has(`${page}_${bank}`), + this.#pushedButtons.has(`${pageNumber}_${bank}`), undefined, undefined, EmberModel.ParameterAccess.ReadWrite ) ), - [NODE_TEXT]: new EmberModel.NumberedTreeNodeImpl( - NODE_TEXT, + [LEGACY_NODE_TEXT]: new EmberModel.NumberedTreeNodeImpl( + LEGACY_NODE_TEXT, new EmberModel.ParameterImpl( EmberModel.ParameterType.String, 'Label', undefined, - drawStyle.text || '', + drawStyle?.text || '', undefined, undefined, EmberModel.ParameterAccess.ReadWrite ) ), - [NODE_TEXT_COLOR]: new EmberModel.NumberedTreeNodeImpl( - NODE_TEXT_COLOR, + [LEGACY_NODE_TEXT_COLOR]: new EmberModel.NumberedTreeNodeImpl( + LEGACY_NODE_TEXT_COLOR, new EmberModel.ParameterImpl( EmberModel.ParameterType.String, 'Text_Color', undefined, - formatColorAsHex(drawStyle.color || 0), + formatColorAsHex(drawStyle?.color || 0), undefined, undefined, EmberModel.ParameterAccess.ReadWrite ) ), - [NODE_BG_COLOR]: new EmberModel.NumberedTreeNodeImpl( - NODE_BG_COLOR, + [LEGACY_NODE_BG_COLOR]: new EmberModel.NumberedTreeNodeImpl( + LEGACY_NODE_BG_COLOR, new EmberModel.ParameterImpl( EmberModel.ParameterType.String, 'Background_Color', undefined, - formatColorAsHex(drawStyle.bgcolor || 0), + formatColorAsHex(drawStyle?.bgcolor || 0), undefined, undefined, EmberModel.ParameterAccess.ReadWrite @@ -178,9 +197,10 @@ class ServiceEmberPlus extends ServiceBase { ) } - output[page] = new EmberModel.NumberedTreeNodeImpl( - page, - new EmberModel.EmberNodeImpl(pages[page].name === 'PAGE' ? 'Page ' + page : pages[page].name), + const pageName = this.page.getPageName(pageNumber) + output[pageNumber] = new EmberModel.NumberedTreeNodeImpl( + pageNumber, + new EmberModel.EmberNodeImpl(!pageName || pageName === 'PAGE' ? 'Page ' + pageNumber : pageName), children ) } @@ -188,6 +208,125 @@ class ServiceEmberPlus extends ServiceBase { return output } + /** + * Get the locations (page/row/column) structure in EmberModel form + * @returns {Record>} + * @access private + */ + #getLocationTree() { + /** @type {import('../Data/Model/ExportModel.js').ExportGridSize} */ + const gridSize = this.userconfig.getKey('gridSize') + if (!gridSize) return {} + + const rowCount = gridSize.maxRow - gridSize.minRow + 1 + const columnCount = gridSize.maxColumn - gridSize.minColumn + 1 + + /** @type {Record>} */ + const output = {} + + for (let pageNumber = 1; pageNumber <= 99; pageNumber++) { + // TODO - the numbers won't be stable when resizing the `min` grid values + + /** @type {Record>} */ + const pageRows = {} + for (let rowI = 0; rowI < rowCount; rowI++) { + const row = gridSize.minRow + rowI + /** @type {Record>} */ + const rowColumns = {} + + for (let colI = 0; colI < columnCount; colI++) { + const column = gridSize.minColumn + colI + + const location = { + pageNumber, + row, + column, + } + const controlId = this.page.getControlIdAt(location) + const control = controlId ? this.controls.getControl(controlId) : undefined + + /** @type {import('../Data/Model/StyleModel.js').DrawStyleModel | null} */ + let drawStyle = control?.getDrawStyle() || null + if (drawStyle?.style !== 'button') drawStyle = null + + rowColumns[colI] = new EmberModel.NumberedTreeNodeImpl( + colI, + new EmberModel.EmberNodeImpl(`Column ${column}`), + { + // [LOCATION_NODE_CONTROLID]: new EmberModel.NumberedTreeNodeImpl( + // LOCATION_NODE_CONTROLID, + // new EmberModel.ParameterImpl(EmberModel.ParameterType.String, 'Control ID', undefined, controlId ?? '') + // ), + [LOCATION_NODE_PRESSED]: new EmberModel.NumberedTreeNodeImpl( + LOCATION_NODE_PRESSED, + new EmberModel.ParameterImpl( + EmberModel.ParameterType.Boolean, + 'Pressed', + undefined, + this.#pushedButtons.has(formatLocation(location)), + undefined, + undefined, + EmberModel.ParameterAccess.ReadWrite + ) + ), + [LOCATION_NODE_TEXT]: new EmberModel.NumberedTreeNodeImpl( + LOCATION_NODE_TEXT, + new EmberModel.ParameterImpl( + EmberModel.ParameterType.String, + 'Label', + undefined, + drawStyle?.text || '', + undefined, + undefined, + EmberModel.ParameterAccess.ReadWrite + ) + ), + [LOCATION_NODE_TEXT_COLOR]: new EmberModel.NumberedTreeNodeImpl( + LOCATION_NODE_TEXT_COLOR, + new EmberModel.ParameterImpl( + EmberModel.ParameterType.String, + 'Text_Color', + undefined, + formatColorAsHex(drawStyle?.color || 0), + undefined, + undefined, + EmberModel.ParameterAccess.ReadWrite + ) + ), + [LOCATION_NODE_BG_COLOR]: new EmberModel.NumberedTreeNodeImpl( + LOCATION_NODE_BG_COLOR, + new EmberModel.ParameterImpl( + EmberModel.ParameterType.String, + 'Background_Color', + undefined, + formatColorAsHex(drawStyle?.bgcolor || 0), + undefined, + undefined, + EmberModel.ParameterAccess.ReadWrite + ) + ), + } + ) + } + + pageRows[rowI] = new EmberModel.NumberedTreeNodeImpl( + rowI, + new EmberModel.EmberNodeImpl(`Row ${row}`), + rowColumns + ) + } + + const pageName = this.page.getPageName(pageNumber) + output[pageNumber] = new EmberModel.NumberedTreeNodeImpl( + pageNumber, + new EmberModel.EmberNodeImpl(!pageName || pageName === 'PAGE' ? 'Page ' + pageNumber : pageName), + pageRows + ) + } + + return output + } + /** * Start the service if it is not already running * @access protected @@ -230,6 +369,11 @@ class ServiceEmberPlus extends ServiceBase { ), }), 1: new EmberModel.NumberedTreeNodeImpl(1, new EmberModel.EmberNodeImpl('pages'), this.#getPagesTree()), + 2: new EmberModel.NumberedTreeNodeImpl( + 2, + new EmberModel.EmberNodeImpl('location'), + this.#getLocationTree() + ), }), } @@ -237,6 +381,8 @@ class ServiceEmberPlus extends ServiceBase { this.server.on('error', this.handleSocketError.bind(this)) this.server.onSetValue = this.setValue.bind(this) this.server.init(root) + + this.currentState = true this.logger.info('Listening on port ' + this.port) this.logger.silly('Listening on port ' + this.port) } catch (/** @type {any} */ e) { @@ -262,20 +408,19 @@ class ServiceEmberPlus extends ServiceBase { const node = parseInt(pathInfo[4]) if (isNaN(page) || isNaN(bank) || isNaN(node)) return false - if (page < 0 || page > 100) return false const controlId = this.page.getControlIdAtOldBankIndex(page, bank) if (!controlId) return false switch (node) { - case NODE_STATE: { + case LEGACY_NODE_STATE: { this.logger.silly(`Change button ${controlId} pressed to ${value}`) this.controls.pressControl(controlId, !!value, `emberplus`) this.server?.update(parameter, { value }) return true } - case NODE_TEXT: { + case LEGACY_NODE_TEXT: { this.logger.silly(`Change button ${controlId} text to ${value}`) const control = this.controls.getControl(controlId) @@ -288,7 +433,7 @@ class ServiceEmberPlus extends ServiceBase { } return false } - case NODE_TEXT_COLOR: { + case LEGACY_NODE_TEXT_COLOR: { const color = parseHexColor(value + '') this.logger.silly(`Change button ${controlId} text color to ${value} (${color})`) @@ -302,7 +447,72 @@ class ServiceEmberPlus extends ServiceBase { } return false } - case NODE_BG_COLOR: { + case LEGACY_NODE_BG_COLOR: { + const color = parseHexColor(value + '') + this.logger.silly(`Change bank ${controlId} background color to ${value} (${color})`) + + const control = this.controls.getControl(controlId) + if (control && control.supportsStyle) { + control.styleSetFields({ bgcolor: color }) + + // Note: this will be replaced shortly after with the value with feedbacks applied + this.server?.update(parameter, { value }) + return true + } + return false + } + } + } else if (pathInfo[0] === '0' && pathInfo[1] === '2' && pathInfo.length === 6) { + const pageNumber = parseInt(pathInfo[2]) + const row = parseInt(pathInfo[3]) + const column = parseInt(pathInfo[4]) + const node = parseInt(pathInfo[5]) + + if (isNaN(pageNumber) || isNaN(row) || isNaN(column) || isNaN(node)) return false + + const controlId = this.page.getControlIdAt({ + pageNumber, + row, + column, + }) + if (!controlId) return false + + switch (node) { + case LOCATION_NODE_PRESSED: { + this.logger.silly(`Change bank ${controlId} pressed to ${value}`) + + this.controls.pressControl(controlId, !!value, `emberplus`) + this.server?.update(parameter, { value }) + return true + } + case LOCATION_NODE_TEXT: { + this.logger.silly(`Change bank ${controlId} text to ${value}`) + + const control = this.controls.getControl(controlId) + if (control && control.supportsStyle) { + control.styleSetFields({ text: value }) + + // Note: this will be replaced shortly after with the value with feedbacks applied + this.server?.update(parameter, { value }) + return true + } + return false + } + case LOCATION_NODE_TEXT_COLOR: { + const color = parseHexColor(value + '') + this.logger.silly(`Change bank ${controlId} text color to ${value} (${color})`) + + const control = this.controls.getControl(controlId) + if (control && control.supportsStyle) { + control.styleSetFields({ color: color }) + + // Note: this will be replaced shortly after with the value with feedbacks applied + this.server?.update(parameter, { value }) + return true + } + return false + } + case LOCATION_NODE_BG_COLOR: { const color = parseHexColor(value + '') this.logger.silly(`Change button ${controlId} background color to ${value} (${color})`) @@ -337,13 +547,15 @@ class ServiceEmberPlus extends ServiceBase { const locationId = `${location.pageNumber}_${bank}` if (pushed && bank) { this.#pushedButtons.add(locationId) + this.#pushedButtons.add(formatLocation(location)) } else { this.#pushedButtons.delete(locationId) + this.#pushedButtons.delete(formatLocation(location)) } if (bank === null) return - this.#updateNodePath(buildPathForButton(location.pageNumber, bank, NODE_STATE), pushed) + this.#updateNodePath(buildPathForButton(location.pageNumber, bank, LEGACY_NODE_STATE), pushed) } /** @@ -355,17 +567,32 @@ class ServiceEmberPlus extends ServiceBase { if (!this.server) return //this.logger.info(`Updating ${page}.${bank} label ${this.banks[page][bank].text}`) + // New 'location' path + const gridSize = this.userconfig.getKey('gridSize') + if (gridSize) { + this.#updateNodePath(buildPathForLocation(gridSize, location, LOCATION_NODE_TEXT), render.style?.text || '') + this.#updateNodePath( + buildPathForLocation(gridSize, location, LOCATION_NODE_TEXT_COLOR), + formatColorAsHex(render.style?.color || 0) + ) + this.#updateNodePath( + buildPathForLocation(gridSize, location, LOCATION_NODE_BG_COLOR), + formatColorAsHex(render.style?.bgcolor || 0) + ) + } + + // Old 'page' path const bank = xyToOldBankIndex(location.column, location.row) if (bank === null) return // Update ember+ with internal state of button - this.#updateNodePath(buildPathForButton(location.pageNumber, bank, NODE_TEXT), render.style?.text || '') + this.#updateNodePath(buildPathForButton(location.pageNumber, bank, LEGACY_NODE_TEXT), render.style?.text || '') this.#updateNodePath( - buildPathForButton(location.pageNumber, bank, NODE_TEXT_COLOR), + buildPathForButton(location.pageNumber, bank, LEGACY_NODE_TEXT_COLOR), formatColorAsHex(render.style?.color || 0) ) this.#updateNodePath( - buildPathForButton(location.pageNumber, bank, NODE_BG_COLOR), + buildPathForButton(location.pageNumber, bank, LEGACY_NODE_BG_COLOR), formatColorAsHex(render.style?.bgcolor || 0) ) } @@ -387,6 +614,20 @@ class ServiceEmberPlus extends ServiceBase { this.server.update(node, { value: newValue }) } } + + /** + * Process an updated userconfig value and enable/disable the module, if necessary. + * @param {string} key - the saved key + * @param {(boolean|number|string)} value - the saved value + * @access public + */ + updateUserConfig(key, value) { + super.updateUserConfig(key, value) + + if (key == 'gridSize') { + this.restartModule() + } + } } export default ServiceEmberPlus diff --git a/lib/Service/HttpApi.js b/lib/Service/HttpApi.js new file mode 100644 index 0000000000..0d3ee79e8e --- /dev/null +++ b/lib/Service/HttpApi.js @@ -0,0 +1,617 @@ +import CoreBase from '../Core/Base.js' +import { ParseAlignment, parseColorToNumber, rgb } from '../Resources/Util.js' +import express from 'express' +import cors from 'cors' +import { formatLocation } from '../Shared/ControlId.js' + +/** + * Class providing the HTTP API. + * + * @extends CoreBase + * @author Håkon Nessjøen + * @author Keith Rocheck + * @author William Viker + * @author Julian Waller + * @since 1.2.0 + * @copyright 2022 Bitfocus AS + * @license + * This program is free software. + * You should have received a copy of the MIT licence as well as the Bitfocus + * Individual Contributor License Agreement for Companion along with + * this program. + * + * You can be released from the requirements of the license by purchasing + * a commercial license. Buying such a license is mandatory as soon as you + * develop commercial activities involving the Companion software without + * disclosing the source code of your own applications. + */ +export class ServiceHttpApi extends CoreBase { + /** + * Root express router + * @type {import('express').Router} + * @access private + */ + #legacyRouter + + /** + * Api router + * @type {import('express').Router} + * @access private + */ + #apiRouter + + /** + * @param {import('../Registry.js').default} registry - the application core + * @param {import('express').Router} router - the http router + */ + constructor(registry, router) { + super(registry, 'http-api', 'Service/HttpApi') + + this.#legacyRouter = router + this.#apiRouter = express.Router() + this.#apiRouter.use(cors()) + + this.#setupLegacyHttpRoutes() + this.#setupNewHttpRoutes() + } + + /** + * + * @param {import('express').Application} app + */ + bindToApp(app) { + app.use( + '/api', + (_req, res, next) => { + // Check that the API is enabled + if (this.userconfig.getKey('http_api_enabled')) { + // Continue + next() + } else { + // Disabled + res.status(403).send() + } + }, + this.#apiRouter + ) + } + + #isLegacyRouteAllowed() { + return !!(this.userconfig.getKey('http_api_enabled') && this.userconfig.getKey('http_legacy_api_enabled')) + } + + #setupLegacyHttpRoutes() { + this.#legacyRouter.options('/press/bank/*', (_req, res, _next) => { + if (!this.#isLegacyRouteAllowed()) return res.status(403).send() + + res.header('Access-Control-Allow-Origin', '*') + res.header('Access-Control-Allow-Methods', 'GET,OPTIONS') + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With') + return res.send(200) + }) + + this.#legacyRouter.get('^/press/bank/:page([0-9]{1,2})/:bank([0-9]{1,2})', (req, res) => { + if (!this.#isLegacyRouteAllowed()) return res.status(403).send() + + res.header('Access-Control-Allow-Origin', '*') + res.header('Access-Control-Allow-Methods', 'GET,OPTIONS') + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With') + + this.logger.info(`Got HTTP /press/bank/ (trigger) page ${req.params.page} button ${req.params.bank}`) + + const controlId = this.registry.page.getControlIdAtOldBankIndex(Number(req.params.page), Number(req.params.bank)) + if (!controlId) { + res.status(404) + res.send('No control at location') + return + } + + this.registry.controls.pressControl(controlId, true, 'http') + + setTimeout(() => { + this.logger.info(`Auto releasing HTTP /press/bank/ page ${req.params.page} button ${req.params.bank}`) + this.registry.controls.pressControl(controlId, false, 'http') + }, 20) + + return res.send('ok') + }) + + this.#legacyRouter.get('^/press/bank/:page([0-9]{1,2})/:bank([0-9]{1,2})/:direction(down|up)', (req, res) => { + if (!this.#isLegacyRouteAllowed()) return res.status(403).send() + + res.header('Access-Control-Allow-Origin', '*') + res.header('Access-Control-Allow-Methods', 'GET,OPTIONS') + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With') + + if (req.params.direction == 'down') { + this.logger.info(`Got HTTP /press/bank/ (DOWN) page ${req.params.page} button ${req.params.bank}`) + + const controlId = this.registry.page.getControlIdAtOldBankIndex( + Number(req.params.page), + Number(req.params.bank) + ) + if (!controlId) { + res.status(404) + res.send('No control at location') + return + } + + this.registry.controls.pressControl(controlId, true, 'http') + } else { + this.logger.info(`Got HTTP /press/bank/ (UP) page ${req.params.page} button ${req.params.bank}`) + + const controlId = this.registry.page.getControlIdAtOldBankIndex( + Number(req.params.page), + Number(req.params.bank) + ) + if (!controlId) { + res.status(404) + res.send('No control at location') + return + } + + this.registry.controls.pressControl(controlId, false, 'http') + } + + return res.send('ok') + }) + + this.#legacyRouter.get('^/rescan', (_req, res) => { + if (!this.#isLegacyRouteAllowed()) return res.status(403).send() + + res.header('Access-Control-Allow-Origin', '*') + res.header('Access-Control-Allow-Methods', 'GET,OPTIONS') + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With') + + this.logger.info('Got HTTP /rescan') + return this.registry.surfaces.triggerRefreshDevices().then( + () => { + res.send('ok') + }, + () => { + res.send('fail') + } + ) + }) + + this.#legacyRouter.get('^/style/bank/:page([0-9]{1,2})/:bank([0-9]{1,2})', (req, res) => { + if (!this.#isLegacyRouteAllowed()) return res.status(403).send() + + res.header('Access-Control-Allow-Origin', '*') + res.header('Access-Control-Allow-Methods', 'GET,OPTIONS') + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With') + + this.logger.info(`Got HTTP /style/bank ${req.params.page} button ${req.params.bank}`) + + const controlId = this.registry.page.getControlIdAtOldBankIndex(Number(req.params.page), Number(req.params.bank)) + if (!controlId) { + res.status(404) + res.send('No control at location') + return + } + + const control = this.registry.controls.getControl(controlId) + + if (!control || !control.supportsStyle) { + res.status(404) + res.send('Not found') + return + } + + const newFields = {} + + if (req.query.bgcolor) { + const value = req.query.bgcolor.replace(/#/, '') + const color = rgb(value.substr(0, 2), value.substr(2, 2), value.substr(4, 2), 16) + if (color !== false) { + newFields.bgcolor = color + } + } + + if (req.query.color) { + const value = req.query.color.replace(/#/, '') + const color = rgb(value.substr(0, 2), value.substr(2, 2), value.substr(4, 2), 16) + if (color !== false) { + newFields.color = color + } + } + + if (req.query.size) { + const value = req.query.size.replace(/pt/i, '') + newFields.size = value + } + + if (req.query.text || req.query.text === '') { + newFields.text = req.query.text + } + + if (req.query.png64 || req.query.png64 === '') { + if (req.query.png64 === '') { + newFields.png64 = null + } else if (!req.query.png64.match(/data:.*?image\/png/)) { + res.status(400) + res.send('png64 must be a base64 encoded png file') + return + } else { + newFields.png64 = req.query.png64 + } + } + + if (req.query.alignment) { + try { + const [, , alignment] = ParseAlignment(req.query.alignment) + newFields.alignment = alignment + } catch (e) { + // Ignore + } + } + + if (req.query.pngalignment) { + try { + const [, , alignment] = ParseAlignment(req.query.pngalignment) + newFields.pngalignment = alignment + } catch (e) { + // Ignore + } + } + + if (Object.keys(newFields).length > 0) { + control.styleSetFields(newFields) + } + + return res.send('ok') + }) + + this.#legacyRouter.get('^/set/custom-variable/:name', (req, res) => { + if (!this.#isLegacyRouteAllowed()) return res.status(403).send() + + res.header('Access-Control-Allow-Origin', '*') + res.header('Access-Control-Allow-Methods', 'GET,OPTIONS') + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With') + + this.logger.debug(`Got HTTP /set/custom-variable/ name ${req.params.name} to value ${req.query.value}`) + const result = this.registry.instance.variable.custom.setValue(req.params.name, req.query.value) + if (result) { + return res.send(result) + } else { + return res.send('ok') + } + }) + } + + #setupNewHttpRoutes() { + // controls by location + this.#apiRouter.post('/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/press', this.#locationPress) + this.#apiRouter.post('/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/down', this.#locationDown) + this.#apiRouter.post('/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/up', this.#locationUp) + this.#apiRouter.post( + '/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/rotate-left', + this.#locationRotateLeft + ) + this.#apiRouter.post( + '/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/rotate-right', + this.#locationRotateRight + ) + this.#apiRouter.post('/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/step', this.#locationStep) + this.#apiRouter.post('/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/style', this.#locationStyle) + + // custom variables + this.#apiRouter.post('/custom-variable/:name/value', this.#customVariableSetValue) + this.#apiRouter.get('/custom-variable/:name/value', this.#customVariableGetValue) + + // surfaces + this.#apiRouter.post('/surfaces/rescan', this.#surfacesRescan) + + // Finally, default all unhandled to 404 + this.#apiRouter.use('*', (_req, res) => { + res.status(404).send('') + }) + } + + /** + * Perform surfaces rescan + * @param {express.Request} _req + * @param {express.Response} res + * @returns {void} + */ + #surfacesRescan = (_req, res) => { + this.logger.info('Got HTTP surface rescan') + this.registry.surfaces.triggerRefreshDevices().then( + () => { + res.send('ok') + }, + () => { + res.status(500).send('fail') + } + ) + } + + /** + * Perform surfaces rescan + * @param {express.Request} req + * @returns {{ location: import('../Resources/Util.js').ControlLocation, controlId: string | null }} + */ + #locationParse = (req) => { + const location = { + pageNumber: Number(req.params.page), + row: Number(req.params.row), + column: Number(req.params.column), + } + + const controlId = this.registry.page.getControlIdAt(location) + + return { + location, + controlId, + } + } + + /** + * Perform control press + * @param {express.Request} req + * @param {express.Response} res + * @returns {void} + */ + #locationPress = (req, res) => { + const { location, controlId } = this.#locationParse(req) + this.logger.info(`Got HTTP control press ${formatLocation(location)} - ${controlId}`) + + if (!controlId) { + res.status(204).send('No control') + return + } + + this.registry.controls.pressControl(controlId, true, 'http') + + setTimeout(() => { + this.logger.info(`Auto releasing HTTP control press ${formatLocation(location)} - ${controlId}`) + + this.registry.controls.pressControl(controlId, false, 'http') + }, 20) + + res.send('ok') + } + + /** + * Perform control down + * @param {express.Request} req + * @param {express.Response} res + * @returns {void} + */ + #locationDown = (req, res) => { + const { location, controlId } = this.#locationParse(req) + this.logger.info(`Got HTTP control down ${formatLocation(location)} - ${controlId}`) + + if (!controlId) { + res.status(204).send('No control') + return + } + + this.registry.controls.pressControl(controlId, true, 'http') + + res.send('ok') + } + + /** + * Perform control up + * @param {express.Request} req + * @param {express.Response} res + * @returns {void} + */ + #locationUp = (req, res) => { + const { location, controlId } = this.#locationParse(req) + this.logger.info(`Got HTTP control up ${formatLocation(location)} - ${controlId}`) + + if (!controlId) { + res.status(204).send('No control') + return + } + + this.registry.controls.pressControl(controlId, false, 'http') + + res.send('ok') + } + + /** + * Perform control rotate left + * @param {express.Request} req + * @param {express.Response} res + * @returns {void} + */ + #locationRotateLeft = (req, res) => { + const { location, controlId } = this.#locationParse(req) + this.logger.info(`Got HTTP control rotate left ${formatLocation(location)} - ${controlId}`) + + if (!controlId) { + res.status(204).send('No control') + return + } + + this.registry.controls.rotateControl(controlId, false, 'http') + + res.send('ok') + } + + /** + * Perform control rotate right + * @param {express.Request} req + * @param {express.Response} res + * @returns {void} + */ + #locationRotateRight = (req, res) => { + const { location, controlId } = this.#locationParse(req) + this.logger.info(`Got HTTP control rotate right ${formatLocation(location)} - ${controlId}`) + + if (!controlId) { + res.status(204).send('No control') + return + } + + this.registry.controls.rotateControl(controlId, true, 'http') + + res.send('ok') + } + + /** + * Set control step + * @param {express.Request} req + * @param {express.Response} res + * @returns {void} + */ + #locationStep = (req, res) => { + const { location, controlId } = this.#locationParse(req) + const step = Number(req.query.step) + + this.logger.info(`Got HTTP control step ${formatLocation(location)} - ${controlId} to ${step}`) + if (!controlId) { + res.status(204).send('No control') + return + } + + const control = this.controls.getControl(controlId) + if (!control || !control.supportsSteps) { + res.status(204).send('No control') + return + } + + if (!control.stepMakeCurrent(step)) { + res.status(400).send('Bad step') + return + } + + res.send('ok') + } + + /** + * Perform control style change + * @param {express.Request} req + * @param {express.Response} res + * @returns {void} + */ + #locationStyle = (req, res) => { + const { location, controlId } = this.#locationParse(req) + this.logger.info(`Got HTTP control syle ${formatLocation(location)} - ${controlId}`) + + if (!controlId) { + res.status(204).send('No control') + return + } + + const control = this.registry.controls.getControl(controlId) + if (!control || !control.supportsStyle) { + res.status(204).send('No control') + return + } + + const newFields = {} + + const bgcolor = req.query.bgcolor || req.body.bgcolor + if (bgcolor !== undefined) { + const newColor = parseColorToNumber(bgcolor) + if (newColor !== false) { + newFields.bgcolor = newColor + } + } + + const fgcolor = req.query.color || req.body.color + if (fgcolor !== undefined) { + const newColor = parseColorToNumber(fgcolor) + if (newColor !== false) { + newFields.color = newColor + } + } + + const size = req.query.size || req.body.size + if (size !== undefined) { + const value = size === 'auto' ? 'auto' : parseInt(size) + + if (!isNaN(Number(value)) || typeof value === 'string') { + newFields.size = value + } + } + + const text = req.query.text ?? req.body.text + if (text !== undefined) { + newFields.text = text + } + + const png64 = req.query.png64 ?? req.body.png64 + if (png64 === '') { + newFields.png64 = null + } else if (png64 && png64.match(/data:.*?image\/png/)) { + newFields.png64 = png64 + } + + const alignment = req.query.alignment || req.body.alignment + if (alignment) { + const [, , tmpAlignment] = ParseAlignment(alignment, false) + newFields.alignment = tmpAlignment + } + + const pngalignment = req.query.pngalignment || req.body.pngalignment + if (pngalignment) { + const [, , tmpAlignment] = ParseAlignment(pngalignment, false) + newFields.pngalignment = tmpAlignment + } + + if (Object.keys(newFields).length > 0) { + control.styleSetFields(newFields) + } + + // TODO - return style + res.send('ok') + } + + /** + * Perform custom variable set value + * @param {express.Request} req + * @param {express.Response} res + * @returns {void} + */ + #customVariableSetValue = (req, res) => { + const variableName = req.params.name + let variableValue = null + + if (req.query.value !== undefined) { + variableValue = req.query.value + } else if (req.body && typeof req.body !== 'object') { + variableValue = req.body.toString().trim() + } + + this.logger.debug(`Got HTTP custom variable set value name "${variableName}" to value "${variableValue}"`) + if (variableValue === null) { + res.status(400).send('No value') + return + } + + const result = this.registry.instance.variable.custom.setValue(variableName, variableValue) + if (result) { + res.status(404).send('Not found') + } else { + res.send('ok') + } + } + + /** + * Retrieve a custom variable current value + * @param {express.Request} req + * @param {express.Response} res + * @returns {void} + */ + #customVariableGetValue = (req, res) => { + const variableName = req.params.name + + this.logger.debug(`Got HTTP custom variable get value name "${variableName}"`) + + const result = this.registry.instance.variable.custom.getValue(variableName) + if (result === undefined) { + res.status(404).send('Not found') + } else { + if (typeof result === 'number') { + res.send(result + '') + } else { + res.send(result) + } + } + } +} diff --git a/lib/Service/OscApi.js b/lib/Service/OscApi.js new file mode 100644 index 0000000000..3ca2f2544c --- /dev/null +++ b/lib/Service/OscApi.js @@ -0,0 +1,420 @@ +import CoreBase from '../Core/Base.js' +import { parseColorToNumber, rgb } from '../Resources/Util.js' +import { formatLocation } from '../Shared/ControlId.js' +import RegexRouter from './RegexRouter.js' + +/** + * Class providing the OSC API. + * + * @extends CoreBase + * @author Håkon Nessjøen + * @author Keith Rocheck + * @author William Viker + * @author Julian Waller + * @since 1.2.0 + * @copyright 2022 Bitfocus AS + * @license + * This program is free software. + * You should have received a copy of the MIT licence as well as the Bitfocus + * Individual Contributor License Agreement for Companion along with + * this program. + * + * You can be released from the requirements of the license by purchasing + * a commercial license. Buying such a license is mandatory as soon as you + * develop commercial activities involving the Companion software without + * disclosing the source code of your own applications. + */ +export class ServiceOscApi extends CoreBase { + /** + * Message router + * @type {RegexRouter} + * @access private + */ + #router + + get router() { + return this.#router + } + + /** + * @param {import('../Registry.js').default} registry - the application core + */ + constructor(registry) { + super(registry, 'osc-api', 'Service/OscApi') + + this.#router = new RegexRouter() + + this.#setupLegacyOscRoutes() + this.#setupNewOscRoutes() + } + + #isLegacyRouteAllowed() { + return !!this.userconfig.getKey('osc_legacy_api_enabled') + } + + #setupLegacyOscRoutes() { + this.#router.addPath('/press/bank/:page(\\d+)/:bank(\\d+)', (match, message) => { + if (!this.#isLegacyRouteAllowed()) return + + const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) + if (!controlId) return + + if (message.args.length > 0 && message.args[0].type == 'i' && message.args[0].value == '1') { + this.logger.info(`Got /press/bank/ (press) for ${controlId}`) + this.controls.pressControl(controlId, true, undefined) + } else if (message.args.length > 0 && message.args[0].type == 'i' && message.args[0].value == '0') { + this.logger.info(`Got /press/bank/ (release) for ${controlId}`) + this.controls.pressControl(controlId, false, undefined) + } else { + this.logger.info(`Got /press/bank/ (trigger)${controlId}`) + this.controls.pressControl(controlId, true, undefined) + + setTimeout(() => { + this.logger.info(`Auto releasing /press/bank/ (trigger)${controlId}`) + this.controls.pressControl(controlId, false, undefined) + }, 20) + } + }) + + this.#router.addPath('/style/bgcolor/:page(\\d+)/:bank(\\d+)', (match, message) => { + if (!this.#isLegacyRouteAllowed()) return + + if (message.args.length > 2) { + const r = message.args[0].value + const g = message.args[1].value + const b = message.args[2].value + if (typeof r === 'number' && typeof g === 'number' && typeof b === 'number') { + const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) + if (!controlId) return + + const control = this.controls.getControl(controlId) + if (control && control.supportsStyle) { + this.logger.info(`Got /style/bgcolor for ${controlId}`) + control.styleSetFields({ bgcolor: rgb(r, g, b) }) + } else { + this.logger.info(`Got /style/bgcolor for unknown control: ${controlId}`) + } + } + } + }) + + this.#router.addPath('/style/color/:page(\\d+)/:bank(\\d+)', (match, message) => { + if (!this.#isLegacyRouteAllowed()) return + + if (message.args.length > 2) { + const r = message.args[0].value + const g = message.args[1].value + const b = message.args[2].value + if (typeof r === 'number' && typeof g === 'number' && typeof b === 'number') { + const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) + if (!controlId) return + + const control = this.controls.getControl(controlId) + if (control && control.supportsStyle) { + this.logger.info(`Got /style/color for ${controlId}`) + control.styleSetFields({ color: rgb(r, g, b) }) + } else { + this.logger.info(`Got /style/color for unknown control: ${controlId}`) + } + } + } + }) + + this.#router.addPath('/style/text/:page(\\d+)/:bank(\\d+)', (match, message) => { + if (!this.#isLegacyRouteAllowed()) return + + if (message.args.length > 0) { + const text = message.args[0].value + if (typeof text === 'string') { + const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) + if (!controlId) return + + const control = this.controls.getControl(controlId) + if (control && control.supportsStyle) { + this.logger.info(`Got /style/text for ${controlId}`) + control.styleSetFields({ text: text }) + } else { + this.logger.info(`Got /style/color for unknown control: ${controlId}`) + } + } + } + }) + + this.#router.addPath('/rescan', (_match, _message) => { + if (!this.#isLegacyRouteAllowed()) return + + this.logger.info('Got /rescan 1') + this.surfaces.triggerRefreshDevices().catch(() => { + this.logger.debug('Scan failed') + }) + }) + } + + #setupNewOscRoutes() { + // controls by location + this.#router.addPath('/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/press', this.#locationPress) + this.#router.addPath('/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/down', this.#locationDown) + this.#router.addPath('/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/up', this.#locationUp) + this.#router.addPath( + '/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/rotate-left', + this.#locationRotateLeft + ) + this.#router.addPath( + '/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/rotate-right', + this.#locationRotateRight + ) + this.#router.addPath('/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/step', this.#locationStep) + + this.#router.addPath( + '/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/style/text', + this.#locationSetStyleText + ) + this.#router.addPath( + '/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/style/color', + this.#locationSetStyleColor + ) + this.#router.addPath( + '/location/:page([0-9]{1,2})/:row(-?[0-9]+)/:column(-?[0-9]+)/style/bgcolor', + this.#locationSetStyleBgcolor + ) + + // custom variables + this.#router.addPath('/custom-variable/:name/value', this.#customVariableSetValue) + + // surfaces + this.#router.addPath('/surfaces/rescan', this.#surfacesRescan) + } + + /** + * Perform surfaces rescan + * @returns {void} + */ + #surfacesRescan = () => { + this.logger.info('Got OSC surface rescan') + this.registry.surfaces.triggerRefreshDevices().catch(() => { + this.logger.debug('Scan failed') + }) + } + + /** + * Parse the location and controlId from a request + * @param {Record} match + * @returns {{ location: import('../Resources/Util.js').ControlLocation, controlId: string | null }} + */ + #locationParse = (match) => { + const location = { + pageNumber: Number(match.page), + row: Number(match.row), + column: Number(match.column), + } + + const controlId = this.registry.page.getControlIdAt(location) + + return { + location, + controlId, + } + } + + /** + * Perform control press + * @param {Record} match + * @param {import('osc').OscReceivedMessage} _message + * @returns {void} + */ + #locationPress = (match, _message) => { + const { location, controlId } = this.#locationParse(match) + this.logger.info(`Got OSC control press ${formatLocation(location)} - ${controlId}`) + if (!controlId) return + + this.registry.controls.pressControl(controlId, true, 'osc') + + setTimeout(() => { + this.logger.info(`Auto releasing OSC control press ${formatLocation(location)} - ${controlId}`) + + this.registry.controls.pressControl(controlId, false, 'osc') + }, 20) + } + + /** + * Perform control down + * @param {Record} match + * @param {import('osc').OscReceivedMessage} _message + * @returns {void} + */ + #locationDown = (match, _message) => { + const { location, controlId } = this.#locationParse(match) + this.logger.info(`Got OSC control down ${formatLocation(location)} - ${controlId}`) + if (!controlId) return + + this.registry.controls.pressControl(controlId, true, 'osc') + } + + /** + * Perform control up + * @param {Record} match + * @param {import('osc').OscReceivedMessage} _message + * @returns {void} + */ + #locationUp = (match, _message) => { + const { location, controlId } = this.#locationParse(match) + this.logger.info(`Got OSC control up ${formatLocation(location)} - ${controlId}`) + if (!controlId) return + + this.registry.controls.pressControl(controlId, false, 'osc') + } + + /** + * Perform control rotate left + * @param {Record} match + * @param {import('osc').OscReceivedMessage} _message + * @returns {void} + */ + #locationRotateLeft = (match, _message) => { + const { location, controlId } = this.#locationParse(match) + this.logger.info(`Got OSC control rotate left ${formatLocation(location)} - ${controlId}`) + if (!controlId) return + + this.registry.controls.rotateControl(controlId, false, 'osc') + } + + /** + * Perform control rotate right + * @param {Record} match + * @param {import('osc').OscReceivedMessage} _message + * @returns {void} + */ + #locationRotateRight = (match, _message) => { + const { location, controlId } = this.#locationParse(match) + this.logger.info(`Got OSC control rotate right ${formatLocation(location)} - ${controlId}`) + if (!controlId) return + + this.registry.controls.rotateControl(controlId, true, 'osc') + } + + /** + * Set control step + * @param {Record} match + * @param {import('osc').OscReceivedMessage} message + * @returns {void} + */ + #locationStep = (match, message) => { + if (message.args.length === 0) return + + const { location, controlId } = this.#locationParse(match) + const step = Number(message.args[0]?.value) + + this.logger.info(`Got OSC control step ${formatLocation(location)} - ${controlId} to ${step}`) + if (!controlId) return + + const control = this.controls.getControl(controlId) + if (!control || !control.supportsSteps) { + return + } + + control.stepMakeCurrent(step) + } + + /** + * Perform control style text change + * @param {Record} match + * @param {import('osc').OscReceivedMessage} message + * @returns {void} + */ + #locationSetStyleText = (match, message) => { + if (message.args.length === 0) return + + const text = message.args[0]?.value + const { location, controlId } = this.#locationParse(match) + this.logger.info(`Got OSC control set text ${formatLocation(location)} - ${controlId}`) + if (!controlId) return + + const control = this.controls.getControl(controlId) + if (!control || !control.supportsStyle) return + + control.styleSetFields({ text: text }) + } + + /** + * Perform control style color change + * @param {Record} match + * @param {import('osc').OscReceivedMessage} message + * @returns {void} + */ + #locationSetStyleColor = (match, message) => { + if (message.args.length === 0) return + + const { location, controlId } = this.#locationParse(match) + this.logger.info(`Got OSC control set color ${formatLocation(location)} - ${controlId}`) + if (!controlId) return + + const control = this.controls.getControl(controlId) + if (!control || !control.supportsStyle) return + + /** @type {number | false} */ + let color = false + if (message.args.length === 3) { + const r = message.args[0].value + const g = message.args[1].value + const b = message.args[2].value + if (typeof r === 'number' && typeof g === 'number' && typeof b === 'number') { + color = rgb(r, g, b) + } + } else { + color = parseColorToNumber(message.args[0].value) + } + + if (color !== false) { + control.styleSetFields({ color }) + } + } + /** + * Perform control style bgcolor change + * @param {Record} match + * @param {import('osc').OscReceivedMessage} message + * @returns {void} + */ + #locationSetStyleBgcolor = (match, message) => { + if (message.args.length === 0) return + + const { location, controlId } = this.#locationParse(match) + this.logger.info(`Got OSC control set bgcolor ${formatLocation(location)} - ${controlId}`) + if (!controlId) return + + const control = this.controls.getControl(controlId) + if (!control || !control.supportsStyle) return + + /** @type {number | false} */ + let color = false + if (message.args.length === 3) { + const r = message.args[0].value + const g = message.args[1].value + const b = message.args[2].value + if (typeof r === 'number' && typeof g === 'number' && typeof b === 'number') { + color = rgb(r, g, b) + } + } else { + color = parseColorToNumber(message.args[0].value) + } + + if (color !== false) { + control.styleSetFields({ bgcolor: color }) + } + } + + /** + * Perform custom variable set value + * @param {Record} match + * @param {import('osc').OscReceivedMessage} message + * @returns {void} + */ + #customVariableSetValue = (match, message) => { + const variableName = match.name + const variableValue = message.args?.[0]?.value + + this.logger.debug(`Got HTTP custom variable set value name "${variableName}" to value "${variableValue}"`) + if (variableValue === undefined) return + + this.registry.instance.variable.custom.setValue(variableName, variableValue.toString()) + } +} diff --git a/lib/Service/OscListener.js b/lib/Service/OscListener.js index 29eee53b88..36950d8c87 100644 --- a/lib/Service/OscListener.js +++ b/lib/Service/OscListener.js @@ -1,6 +1,5 @@ -import { rgb } from '../Resources/Util.js' import ServiceOscBase from './OscBase.js' -import RegexRouter from './RegexRouter.js' +import { ServiceOscApi } from './OscApi.js' /** * Class providing OSC receive services. @@ -32,11 +31,11 @@ class ServiceOscListener extends ServiceOscBase { port = 12321 /** - * Message router - * @type {RegexRouter} + * Api router + * @type {ServiceOscApi} * @access private */ - #router + #api /** * @param {import('../Registry.js').default} registry - the application core @@ -46,118 +45,21 @@ class ServiceOscListener extends ServiceOscBase { this.init() - this.#router = new RegexRouter() - - this.#setupOscRoutes() + this.#api = new ServiceOscApi(registry) } /** * Process an incoming message from a client - * @param {import('osc').OscMessage} message - the incoming message part + * @param {import('osc').OscReceivedMessage} message - the incoming message part * @access protected */ processIncoming(message) { try { - this.#router.processMessage(message.address, message) + this.#api.router.processMessage(message.address, message) } catch (error) { this.logger.warn('OSC Error: ' + error) } } - - #setupOscRoutes() { - this.#router.addPath('/press/bank/:page(\\d+)/:bank(\\d+)', (match, message) => { - const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) - if (!controlId) return - - if (message.args.length > 0 && message.args[0].type == 'i' && message.args[0].value == '1') { - this.logger.info(`Got /press/bank/ (press) for ${controlId}`) - this.controls.pressControl(controlId, true, undefined) - } else if (message.args.length > 0 && message.args[0].type == 'i' && message.args[0].value == '0') { - this.logger.info(`Got /press/bank/ (release) for ${controlId}`) - this.controls.pressControl(controlId, false, undefined) - } else { - this.logger.info(`Got /press/bank/ (trigger)${controlId}`) - this.controls.pressControl(controlId, true, undefined) - - setTimeout(() => { - this.logger.info(`Auto releasing /press/bank/ (trigger)${controlId}`) - this.controls.pressControl(controlId, false, undefined) - }, 20) - } - }) - - this.#router.addPath('/style/bgcolor/:page(\\d+)/:bank(\\d+)', (match, message) => { - if (message.args.length > 2) { - const r = message.args[0].value - const g = message.args[1].value - const b = message.args[2].value - if (typeof r === 'number' && typeof g === 'number' && typeof b === 'number') { - const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) - if (!controlId) return - - const control = this.controls.getControl(controlId) - if (control && control.supportsStyle) { - this.logger.info(`Got /style/bgcolor for ${controlId}`) - control.styleSetFields({ bgcolor: rgb(r, g, b) }) - } else { - this.logger.info(`Got /style/bgcolor for unknown control: ${controlId}`) - } - } - } - }) - - this.#router.addPath('/style/color/:page(\\d+)/:bank(\\d+)', (match, message) => { - if (message.args.length > 2) { - const r = message.args[0].value - const g = message.args[1].value - const b = message.args[2].value - if (typeof r === 'number' && typeof g === 'number' && typeof b === 'number') { - const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) - if (!controlId) return - - const control = this.controls.getControl(controlId) - if (control && control.supportsStyle) { - this.logger.info(`Got /style/color for ${controlId}`) - control.styleSetFields({ color: rgb(r, g, b) }) - } else { - this.logger.info(`Got /style/color for unknown control: ${controlId}`) - } - } - } - }) - - this.#router.addPath('/style/text/:page(\\d+)/:bank(\\d+)', (match, message) => { - if (message.args.length > 0) { - const text = message.args[0].value - if (typeof text === 'string') { - const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) - if (!controlId) return - - const control = this.controls.getControl(controlId) - if (control && control.supportsStyle) { - this.logger.info(`Got /style/text for ${controlId}`) - control.styleSetFields({ text: text }) - } else { - this.logger.info(`Got /style/color for unknown control: ${controlId}`) - } - } - } - }) - - this.#router.addPath('/rescan', (_match, _message) => { - this.logger.info('Got /rescan 1') - this.surfaces.triggerRefreshDevices().catch(() => { - this.logger.debug('Scan failed') - }) - }) - - this.#router.addPath('/custom-variable/:name/value', (match, message) => { - if (match.name && message.args.length > 0) { - this.logger.debug(`Setting custom-variable ${match.name} to value ${message.args[0].value}`) - this.instance.variable.custom.setValue(match.name, message.args[0].value) - } - }) - } } export default ServiceOscListener diff --git a/lib/Service/Tcp.js b/lib/Service/Tcp.js index ecf52492ee..9492b5ee17 100644 --- a/lib/Service/Tcp.js +++ b/lib/Service/Tcp.js @@ -1,5 +1,5 @@ import { decimalToRgb } from '../Resources/Util.js' -import { ApiMessageError } from './Api.js' +import { ApiMessageError, ServiceTcpUdpApi } from './TcpUdpApi.js' import ServiceTcpBase from './TcpBase.js' import { xyToOldBankIndex } from '../Shared/ControlId.js' @@ -27,7 +27,7 @@ import { xyToOldBankIndex } from '../Shared/ControlId.js' class ServiceTcp extends ServiceTcpBase { /** * The service api command processor - * @type {import('./Api.js').default} + * @type {ServiceTcpUdpApi} * @access protected * @readonly */ @@ -42,11 +42,11 @@ class ServiceTcp extends ServiceTcpBase { /** * @param {import('../Registry.js').default} registry - the application core - * @param {import('./Api.js').default} api - the handler for incoming api commands */ - constructor(registry, api) { + constructor(registry) { super(registry, 'tcp', 'Service/Tcp', 'tcp_enabled', 'tcp_listen_port') - this.#api = api + + this.#api = new ServiceTcpUdpApi(registry, 'tcp', 'tcp_legacy_api_enabled') this.graphics.on('button_drawn', (location, render) => { const bgcolor = render.style?.bgcolor || 0 diff --git a/lib/Service/TcpUdpApi.js b/lib/Service/TcpUdpApi.js new file mode 100644 index 0000000000..b7c8a9acd9 --- /dev/null +++ b/lib/Service/TcpUdpApi.js @@ -0,0 +1,549 @@ +import CoreBase from '../Core/Base.js' +import { parseColorToNumber } from '../Resources/Util.js' +import { formatLocation } from '../Shared/ControlId.js' +import RegexRouter from './RegexRouter.js' + +/** + * Common API command processing for {@link ServiceTcp} and {@link ServiceUdp}. + * + * @extends CoreBase + * @author Håkon Nessjøen + * @author Keith Rocheck + * @author William Viker + * @author Julian Waller + * @since 1.3.0 + * @copyright 2022 Bitfocus AS + * @license + * This program is free software. + * You should have received a copy of the MIT licence as well as the Bitfocus + * Individual Contributor License Agreement for Companion along with + * this program. + * + * You can be released from the requirements of the license by purchasing + * a commercial license. Buying such a license is mandatory as soon as you + * develop commercial activities involving the Companion software without + * disclosing the source code of your own applications. + */ +export class ServiceTcpUdpApi extends CoreBase { + /** + * Message router + * @type {RegexRouter} + * @access private + * @readonly + */ + #router + + /** + * Protocol name + * @type {string} + * @access private + * @readonly + */ + #protocolName + + /** + * Userconfig key to enable/disable legacy routes + * @type {string | null} + * @access private + * @readonly + */ + #legacyRoutesEnableKey + + get router() { + return this.#router + } + + /** + * @param {import('../Registry.js').default} registry - the core registry + * @param {string} protocolName - the protocol name + * @param {string | null} legacyRoutesEnableKey - Userconfig key to enable/disable legacy routes + */ + constructor(registry, protocolName, legacyRoutesEnableKey) { + super(registry, 'api', 'Service/Api') + + this.#router = new RegexRouter(() => { + throw new ApiMessageError('Syntax error') + }) + this.#protocolName = protocolName + this.#legacyRoutesEnableKey = legacyRoutesEnableKey + + this.#setupLegacyRoutes() + this.#setupNewRoutes() + } + + #checkLegacyRouteAllowed() { + if (this.#legacyRoutesEnableKey && !this.userconfig.getKey(this.#legacyRoutesEnableKey)) { + throw new ApiMessageError('Deprecated commands are disabled') + } + } + + #setupLegacyRoutes() { + this.#router.addPath('page-set :page(\\d+) :surfaceId', (match) => { + this.#checkLegacyRouteAllowed() + + const page = parseInt(match.page) + const surfaceId = match.surfaceId + + this.surfaces.devicePageSet(surfaceId, page) + + return `If ${surfaceId} is connected` + }) + + this.#router.addPath('page-up :surfaceId', (match) => { + this.#checkLegacyRouteAllowed() + + const surfaceId = match.surfaceId + + this.surfaces.devicePageUp(surfaceId) + + return `If ${surfaceId} is connected` + }) + + this.#router.addPath('page-down :surfaceId', (match) => { + this.#checkLegacyRouteAllowed() + + const surfaceId = match.surfaceId + + this.surfaces.devicePageDown(surfaceId) + + return `If ${surfaceId} is connected` + }) + + this.#router.addPath('bank-press :page(\\d+) :bank(\\d+)', (match) => { + this.#checkLegacyRouteAllowed() + + const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) + if (!controlId) throw new ApiMessageError('Page/bank out of range') + + this.logger.info(`Got bank-press (trigger) ${controlId}`) + + if (!this.controls.pressControl(controlId, true, this.#protocolName)) { + throw new ApiMessageError('Page/bank out of range') + } + + setTimeout(() => { + this.logger.info(`Auto releasing bank-press ${controlId}`) + this.controls.pressControl(controlId, false, this.#protocolName) + }, 20) + }) + + this.#router.addPath('bank-down :page(\\d+) :bank(\\d+)', (match) => { + this.#checkLegacyRouteAllowed() + + const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) + if (!controlId) throw new ApiMessageError('Page/bank out of range') + + this.logger.info(`Got bank-down (trigger) ${controlId}`) + + if (!this.controls.pressControl(controlId, true, this.#protocolName)) { + throw new ApiMessageError('Page/bank out of range') + } + }) + + this.#router.addPath('bank-up :page(\\d+) :bank(\\d+)', (match) => { + this.#checkLegacyRouteAllowed() + + const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) + if (!controlId) throw new ApiMessageError('Page/bank out of range') + + this.logger.info(`Got bank-up (trigger) ${controlId}`) + + if (!this.controls.pressControl(controlId, false, this.#protocolName)) { + throw new ApiMessageError('Page/bank out of range') + } + }) + + this.#router.addPath('bank-step :page(\\d+) :bank(\\d+) :step(\\d+)', (match) => { + this.#checkLegacyRouteAllowed() + + const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) + if (!controlId) throw new ApiMessageError('Page/bank out of range') + + const step = parseInt(match.step) + + this.logger.info(`Got bank-step (trigger) ${controlId} ${step}`) + + if (isNaN(step) || step <= 0) throw new ApiMessageError('Step out of range') + + const control = this.controls.getControl(controlId) + if (!control || !control.supportsSteps) throw new ApiMessageError('Invalid control') + + if (!control.stepMakeCurrent(step)) throw new ApiMessageError('Step out of range') + }) + + this.#router.addPath('style bank :page(\\d+) :bank(\\d+) text{ :text}?', (match) => { + this.#checkLegacyRouteAllowed() + + const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) + if (!controlId) throw new ApiMessageError('Page/bank out of range') + + const control = this.controls.getControl(controlId) + + if (control && control.supportsStyle) { + const text = match.text || '' + + control.styleSetFields({ text: text }) + } else { + throw new ApiMessageError('Page/bank out of range') + } + }) + + this.#router.addPath('style bank :page(\\d+) :bank(\\d+) bgcolor #:color([a-f\\d]+)', (match) => { + this.#checkLegacyRouteAllowed() + + const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) + if (!controlId) throw new ApiMessageError('Page/bank out of range') + + const color = parseInt(match.color, 16) + if (isNaN(color)) throw new ApiMessageError('Invalid color') + + const control = this.controls.getControl(controlId) + + if (control && control.supportsStyle) { + control.styleSetFields({ bgcolor: color }) + } else { + throw new ApiMessageError('Page/bank out of range') + } + }) + + this.#router.addPath('style bank :page(\\d+) :bank(\\d+) color #:color([a-f\\d]+)', (match) => { + this.#checkLegacyRouteAllowed() + + const controlId = this.page.getControlIdAtOldBankIndex(Number(match.page), Number(match.bank)) + if (!controlId) throw new ApiMessageError('Page/bank out of range') + + const color = parseInt(match.color, 16) + if (isNaN(color)) throw new ApiMessageError('Invalid color') + + const control = this.controls.getControl(controlId) + + if (control && control.supportsStyle) { + control.styleSetFields({ color: color }) + } else { + throw new ApiMessageError('Page/bank out of range') + } + }) + + this.#router.addPath('rescan', async () => { + this.#checkLegacyRouteAllowed() + + this.logger.debug('Rescanning USB') + + try { + await this.surfaces.triggerRefreshDevices() + } catch (e) { + throw new ApiMessageError('Scan failed') + } + }) + } + + #setupNewRoutes() { + // surface pages + this.#router.addPath('surface :surfaceId page-set :page(\\d+)', this.#surfaceSetPage) + this.#router.addPath('surface :surfaceId page-up', this.#surfacePageUp) + this.#router.addPath('surface :surfaceId page-down', this.#surfacePageDown) + + // control by location + this.#router.addPath('location :page(\\d+)/:row(\\d+)/:column(\\d+) press', this.#locationPress) + this.#router.addPath('location :page(\\d+)/:row(\\d+)/:column(\\d+) down', this.#locationDown) + this.#router.addPath('location :page(\\d+)/:row(\\d+)/:column(\\d+) up', this.#locationUp) + this.#router.addPath('location :page(\\d+)/:row(\\d+)/:column(\\d+) rotate-left', this.#locationRotateLeft) + this.#router.addPath('location :page(\\d+)/:row(\\d+)/:column(\\d+) rotate-right', this.#locationRotateRight) + this.#router.addPath('location :page(\\d+)/:row(\\d+)/:column(\\d+) set-step :step(\\d+)', this.#locationSetStep) + + this.#router.addPath('location :page(\\d+)/:row(\\d+)/:column(\\d+) style text{ :text}?', this.#locationStyleText) + this.#router.addPath( + 'location :page(\\d+)/:row(\\d+)/:column(\\d+) style color :color(.+)', + this.#locationStyleColor + ) + this.#router.addPath( + 'location :page(\\d+)/:row(\\d+)/:column(\\d+) style bgcolor :bgcolor(.+)', + this.#locationStyleBgcolor + ) + + // surfaces + this.#router.addPath('surfaces rescan', this.#surfacesRescan) + + // custom variables + this.#router.addPath('custom-variable :name set-value :value(.*)', this.#customVariableSetValue) + } + + /** + * Perform surface set to page + * @param {Record} match + * @returns {string | void} + */ + #surfaceSetPage = (match) => { + const page = parseInt(match.page) + const surfaceId = match.surfaceId + + this.surfaces.devicePageSet(surfaceId, page) + + return `If ${surfaceId} is connected` + } + + /** + * Perform surface page up + * @param {Record} match + * @returns {string | void} + */ + #surfacePageUp = (match) => { + const surfaceId = match.surfaceId + + this.surfaces.devicePageUp(surfaceId) + + return `If ${surfaceId} is connected` + } + + /** + * Perform surface page down + * @param {Record} match + * @returns {string | void} + */ + #surfacePageDown = (match) => { + const surfaceId = match.surfaceId + + this.surfaces.devicePageDown(surfaceId) + + return `If ${surfaceId} is connected` + } + + /** + * Perform control press + * @param {Record} match + * @returns {void} + */ + #locationPress = (match) => { + const { location, controlId } = this.#locationParse(match) + + this.logger.info(`Got location press at ${formatLocation(location)} (${controlId})`) + + if (!controlId || !this.controls.pressControl(controlId, true, this.#protocolName)) { + throw new ApiMessageError('No control at location') + } + + setTimeout(() => { + this.logger.info(`Auto releasing ${formatLocation(location)} (${controlId})`) + this.controls.pressControl(controlId, false, this.#protocolName) + }, 20) + } + + /** + * Perform control down + * @param {Record} match + * @returns {void} + */ + #locationDown = (match) => { + const { location, controlId } = this.#locationParse(match) + + this.logger.info(`Got location down at ${formatLocation(location)} (${controlId})`) + + if (!controlId || !this.controls.pressControl(controlId, true, this.#protocolName)) { + throw new ApiMessageError('No control at location') + } + } + + /** + * Perform control up + * @param {Record} match + * @returns {void} + */ + #locationUp = (match) => { + const { location, controlId } = this.#locationParse(match) + + this.logger.info(`Got location up at ${formatLocation(location)} (${controlId})`) + + if (!controlId || !this.controls.pressControl(controlId, false, this.#protocolName)) { + throw new ApiMessageError('No control at location') + } + } + + /** + * Perform control rotate left + * @param {Record} match + * @returns {void} + */ + #locationRotateLeft = (match) => { + const { location, controlId } = this.#locationParse(match) + + this.logger.info(`Got location rotate-left at ${formatLocation(location)} (${controlId})`) + + if (!controlId || !this.controls.rotateControl(controlId, false, this.#protocolName)) { + throw new ApiMessageError('No control at location') + } + } + + /** + * Perform control rotate right + * @param {Record} match + * @returns {void} + */ + #locationRotateRight = (match) => { + const { location, controlId } = this.#locationParse(match) + + this.logger.info(`Got location rotate-right at ${formatLocation(location)} (${controlId})`) + + if (!controlId || !this.controls.rotateControl(controlId, true, this.#protocolName)) { + throw new ApiMessageError('No control at location') + } + } + + /** + * Set control step + * @param {Record} match + * @returns {void} + */ + #locationSetStep = (match) => { + const step = parseInt(match.step) + const { location, controlId } = this.#locationParse(match) + + this.logger.info(`Got location set-step at ${formatLocation(location)} (${controlId}) to ${step}`) + if (!controlId) { + throw new ApiMessageError('No control at location') + } + + const control = this.controls.getControl(controlId) + if (!control || !control.supportsSteps) { + throw new ApiMessageError('No control at location') + } + + if (!control.stepMakeCurrent(step)) throw new ApiMessageError('Step out of range') + } + + /** + * Perform control style text change + * @param {Record} match + * @returns {void} + */ + #locationStyleText = (match) => { + const { location, controlId } = this.#locationParse(match) + + this.logger.info(`Got location style text at ${formatLocation(location)} (${controlId}) `) + if (!controlId) { + throw new ApiMessageError('No control at location') + } + + const control = this.controls.getControl(controlId) + if (control && control.supportsStyle) { + const text = match.text || '' + + control.styleSetFields({ text: text }) + } else { + throw new ApiMessageError('No control at location') + } + } + + /** + * Perform control style color change + * @param {Record} match + * @returns {void} + */ + #locationStyleColor = (match) => { + const { location, controlId } = this.#locationParse(match) + + this.logger.info(`Got location style color at ${formatLocation(location)} (${controlId}) `) + if (!controlId) { + throw new ApiMessageError('No control at location') + } + + const control = this.controls.getControl(controlId) + if (control && control.supportsStyle) { + const color = parseColorToNumber(match.color) + + control.styleSetFields({ color: color }) + } else { + throw new ApiMessageError('No control at location') + } + } + + /** + * Perform control style bgcolor change + * @param {Record} match + * @returns {void} + */ + #locationStyleBgcolor = (match) => { + const { location, controlId } = this.#locationParse(match) + + this.logger.info(`Got location style bgcolor at ${formatLocation(location)} (${controlId}) `) + if (!controlId) { + throw new ApiMessageError('No control at location') + } + + const control = this.controls.getControl(controlId) + if (control && control.supportsStyle) { + const color = parseColorToNumber(match.bgcolor) + + control.styleSetFields({ bgcolor: color }) + } else { + throw new ApiMessageError('No control at location') + } + } + + /** + * Perform surfaces rescan + * @param {Record} _match + * @returns {Promise} + */ + #surfacesRescan = async (_match) => { + this.logger.debug('Rescanning USB') + + try { + await this.surfaces.triggerRefreshDevices() + } catch (e) { + throw new ApiMessageError('Scan failed') + } + } + + /** + * Perform custom variable set value + * @param {Record} match + * @returns {void} + */ + #customVariableSetValue = (match) => { + const result = this.instance.variable.custom.setValue(match.name, match.value) + if (result) { + throw new ApiMessageError(result) + } + } + + /** + * Parse the location and controlId from a request + * @param {Record} match + * @returns {{ location: import('../Resources/Util.js').ControlLocation, controlId: string | null }} + */ + #locationParse = (match) => { + const location = { + pageNumber: Number(match.page), + row: Number(match.row), + column: Number(match.column), + } + + const controlId = this.registry.page.getControlIdAt(location) + + return { + location, + controlId, + } + } + + /** + * Fire an API command from a raw TCP/UDP command + * @param {string} data - the raw command + * @returns {Promise} + */ + async parseApiCommand(data) { + data = data.trim() + this.logger.silly(`API parsing command: ${data}`) + + return this.#router.processMessage(data) + } +} + +export class ApiMessageError extends Error { + /** + * @param {string} message + */ + constructor(message) { + super(message) + } +} diff --git a/lib/Service/Udp.js b/lib/Service/Udp.js index 525270a265..b06964a538 100644 --- a/lib/Service/Udp.js +++ b/lib/Service/Udp.js @@ -1,4 +1,4 @@ -import ServiceApi from './Api.js' +import { ServiceTcpUdpApi } from './TcpUdpApi.js' import ServiceUdpBase from './UdpBase.js' /** @@ -25,7 +25,7 @@ import ServiceUdpBase from './UdpBase.js' class ServiceUdp extends ServiceUdpBase { /** * The service api command processor - * @type {ServiceApi} + * @type {ServiceTcpUdpApi} * @access protected * @readonly */ @@ -40,11 +40,11 @@ class ServiceUdp extends ServiceUdpBase { /** * @param {import('../Registry.js').default} registry - the application core - * @param {import('./Api.js').default} api - the handler for incoming api commands */ - constructor(registry, api) { + constructor(registry) { super(registry, 'udp', 'Service/Udp', 'udp_enabled', 'udp_listen_port') - this.#api = api + + this.#api = new ServiceTcpUdpApi(registry, 'udp', 'udp_legacy_api_enabled') this.init() } diff --git a/lib/UI/Express.js b/lib/UI/Express.js index 0df55e8e6d..bc285f7a28 100644 --- a/lib/UI/Express.js +++ b/lib/UI/Express.js @@ -17,7 +17,7 @@ import Express from 'express' import path from 'path' -import { isPackaged, ParseAlignment, rgb } from '../Resources/Util.js' +import { isPackaged } from '../Resources/Util.js' import cors from 'cors' import fs from 'fs' // @ts-ignore @@ -71,6 +71,8 @@ class UIExpress { constructor(registry) { this.registry = registry + this.legacyApiRouter = Express.Router() + this.app.use(cors()) this.app.use((_req, res, next) => { @@ -103,190 +105,7 @@ class UIExpress { } }) - this.app.options('/press/bank/*', (_req, res, _next) => { - res.header('Access-Control-Allow-Origin', '*') - res.header('Access-Control-Allow-Methods', 'GET,OPTIONS') - res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With') - res.send(200) - }) - - this.app.get('^/press/bank/:page([0-9]{1,2})/:bank([0-9]{1,2})', (req, res) => { - res.header('Access-Control-Allow-Origin', '*') - res.header('Access-Control-Allow-Methods', 'GET,OPTIONS') - res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With') - - this.logger.info(`Got HTTP /press/bank/ (trigger) page ${req.params.page} button ${req.params.bank}`) - - const controlId = this.registry.page.getControlIdAtOldBankIndex(Number(req.params.page), Number(req.params.bank)) - if (!controlId) { - res.status(404) - res.send('No control at location') - return - } - - this.registry.controls.pressControl(controlId, true, undefined) - - setTimeout(() => { - this.logger.info(`Auto releasing HTTP /press/bank/ page ${req.params.page} button ${req.params.bank}`) - this.registry.controls.pressControl(controlId, false, undefined) - }, 20) - - res.send('ok') - }) - - this.app.get('^/press/bank/:page([0-9]{1,2})/:bank([0-9]{1,2})/:direction(down|up)', (req, res) => { - res.header('Access-Control-Allow-Origin', '*') - res.header('Access-Control-Allow-Methods', 'GET,OPTIONS') - res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With') - - if (req.params.direction == 'down') { - this.logger.info(`Got HTTP /press/bank/ (DOWN) page ${req.params.page} button ${req.params.bank}`) - - const controlId = this.registry.page.getControlIdAtOldBankIndex( - Number(req.params.page), - Number(req.params.bank) - ) - if (!controlId) { - res.status(404) - res.send('No control at location') - return - } - - this.registry.controls.pressControl(controlId, true, undefined) - } else { - this.logger.info(`Got HTTP /press/bank/ (UP) page ${req.params.page} button ${req.params.bank}`) - - const controlId = this.registry.page.getControlIdAtOldBankIndex( - Number(req.params.page), - Number(req.params.bank) - ) - if (!controlId) { - res.status(404) - res.send('No control at location') - return - } - - this.registry.controls.pressControl(controlId, false, undefined) - } - - res.send('ok') - }) - - this.app.get('^/rescan', (_req, res) => { - res.header('Access-Control-Allow-Origin', '*') - res.header('Access-Control-Allow-Methods', 'GET,OPTIONS') - res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With') - - this.logger.info('Got HTTP /rescan') - this.registry.surfaces.triggerRefreshDevices().then( - () => { - res.send('ok') - }, - () => { - res.send('fail') - } - ) - }) - - this.app.get('^/style/bank/:page([0-9]{1,2})/:bank([0-9]{1,2})', (req, res) => { - res.header('Access-Control-Allow-Origin', '*') - res.header('Access-Control-Allow-Methods', 'GET,OPTIONS') - res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With') - - this.logger.info(`Got HTTP /style/bank ${req.params.page} button ${req.params.bank}`) - - const controlId = this.registry.page.getControlIdAtOldBankIndex(Number(req.params.page), Number(req.params.bank)) - if (!controlId) { - res.status(404) - res.send('No control at location') - return - } - - const control = this.registry.controls.getControl(controlId) - - if (!control || !control.supportsStyle) { - res.status(404) - res.send('Not found') - return - } - - const newFields = {} - - if (req.query.bgcolor) { - const value = req.query.bgcolor.replace(/#/, '') - const color = rgb(value.substr(0, 2), value.substr(2, 2), value.substr(4, 2), 16) - if (color !== false) { - newFields.bgcolor = color - } - } - - if (req.query.color) { - const value = req.query.color.replace(/#/, '') - const color = rgb(value.substr(0, 2), value.substr(2, 2), value.substr(4, 2), 16) - if (color !== false) { - newFields.color = color - } - } - - if (req.query.size) { - const value = req.query.size.replace(/pt/i, '') - newFields.size = value - } - - if (req.query.text || req.query.text === '') { - newFields.text = req.query.text - } - - if (req.query.png64 || req.query.png64 === '') { - if (req.query.png64 === '') { - newFields.png64 = null - } else if (!req.query.png64.match(/data:.*?image\/png/)) { - res.status(400) - res.send('png64 must be a base64 encoded png file') - return - } else { - newFields.png64 = req.query.png64 - } - } - - if (req.query.alignment) { - try { - const [, , alignment] = ParseAlignment(req.query.alignment) - newFields.alignment = alignment - } catch (e) { - // Ignore - } - } - - if (req.query.pngalignment) { - try { - const [, , alignment] = ParseAlignment(req.query.pngalignment) - newFields.pngalignment = alignment - } catch (e) { - // Ignore - } - } - - if (Object.keys(newFields).length > 0) { - control.styleSetFields(newFields) - } - - res.send('ok') - }) - - this.app.get('^/set/custom-variable/:name', (req, res) => { - res.header('Access-Control-Allow-Origin', '*') - res.header('Access-Control-Allow-Methods', 'GET,OPTIONS') - res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With') - - this.logger.debug(`Got HTTP /set/custom-variable/ name ${req.params.name} to value ${req.query.value}`) - const result = this.registry.instance.variable.custom.setValue(req.params.name, req.query.value) - if (result) { - res.send(result) - } else { - res.send('ok') - } - }) + this.app.use(this.legacyApiRouter) /** * We don't want to ship hundreds of loose files, so instead we can serve the webui files from a zip file diff --git a/package.json b/package.json index 9b2e7beb2f..d0db3fe36c 100755 --- a/package.json +++ b/package.json @@ -48,15 +48,18 @@ "@types/pngjs": "^6.0.2", "@types/semver": "^7.5.3", "@types/socketcluster-client": "^16.0.1", + "@types/supertest": "^2.0.15", "@types/uuid": "^9.0.5", "@types/workerpool": "^6.4.4", "@types/ws": "^8.5.7", "chokidar": "^3.5.3", "dotenv": "^16.3.1", "jest": "^29.7.0", + "jest-mock-extended": "^3.0.5", "jsdoc": "^4.0.2", "octokit": "^3.1.1", "prettier": "^3.0.3", + "supertest": "^6.3.3", "tar": "^6.2.0", "typescript": "^5.2.2", "webpack": "^5.89.0", diff --git a/test/Service/HttpApi.test.js b/test/Service/HttpApi.test.js new file mode 100644 index 0000000000..911dccc6ae --- /dev/null +++ b/test/Service/HttpApi.test.js @@ -0,0 +1,1018 @@ +import { jest } from '@jest/globals' +import { mock } from 'jest-mock-extended' +import { ServiceHttpApi } from '../../lib/Service/HttpApi' +import express from 'express' +import supertest from 'supertest' +import bodyParser from 'body-parser' +import { rgb } from '../../lib/Resources/Util' + +const mockOptions = { + fallbackMockImplementation: () => { + throw new Error('not mocked') + }, +} + +describe('HttpApi', () => { + function createService() { + const logger = mock( + { + info: jest.fn(), + debug: jest.fn(), + }, + mockOptions + ) + const logController = mock( + { + createLogger: () => logger, + }, + mockOptions + ) + const registry = mock( + { + log: logController, + surfaces: mock({}, mockOptions), + page: mock({}, mockOptions), + controls: mock({}, mockOptions), + userconfig: { + // Force config to return true + getKey: () => true, + }, + instance: mock( + { + variable: mock( + { + custom: mock({}, mockOptions), + }, + mockOptions + ), + }, + mockOptions + ), + }, + mockOptions + ) + + const legacyRouter = express.Router() + const service = new ServiceHttpApi(registry, legacyRouter) + + const app = express() + + app.use(bodyParser.text()) + app.use(bodyParser.json()) + + app.use(legacyRouter) + service.bindToApp(app) + + return { + app, + registry, + service, + logger, + } + } + + describe('surfaces', () => { + describe('rescan', () => { + test('ok', async () => { + const { app, registry } = createService() + registry.surfaces.triggerRefreshDevices.mockResolvedValue() + + // Perform the request + const res = await supertest(app).post('/api/surfaces/rescan').send() + expect(res.status).toBe(200) + expect(res.text).toBe('ok') + }) + + test('failed', async () => { + const { app, registry } = createService() + registry.surfaces.triggerRefreshDevices.mockRejectedValue('internal error') + + // Perform the request + const res = await supertest(app).post('/api/surfaces/rescan').send() + expect(res.status).toBe(500) + expect(res.text).toBe('fail') + }) + }) + }) + + describe('custom-variable', () => { + describe('set value', () => { + test('no value', async () => { + const { app } = createService() + + // Perform the request + const res = await supertest(app).post('/api/custom-variable/my-var-name/value').send() + expect(res.status).toBe(400) + expect(res.text).toBe('No value') + }) + + test('ok from query', async () => { + const { app, registry } = createService() + + const mockFn = registry.instance.variable.custom.setValue + mockFn.mockReturnValue() + + // Perform the request + const res = await supertest(app).post('/api/custom-variable/my-var-name/value?value=123').send() + expect(res.status).toBe(200) + expect(res.text).toBe('ok') + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name', '123') + }) + + test('ok from body', async () => { + const { app, registry } = createService() + + const mockFn = registry.instance.variable.custom.setValue + mockFn.mockReturnValue() + + // Perform the request + const res = await supertest(app) + .post('/api/custom-variable/my-var-name/value') + .set('Content-Type', 'text/plain') + .send('def') + expect(res.status).toBe(200) + expect(res.text).toBe('ok') + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name', 'def') + }) + + test('unknown name', async () => { + const { app, registry } = createService() + + const mockFn = registry.instance.variable.custom.setValue + mockFn.mockReturnValue('Unknown name') + + // Perform the request + const res = await supertest(app) + .post('/api/custom-variable/my-var-name/value') + .set('Content-Type', 'text/plain') + .send('def') + expect(res.status).toBe(404) + expect(res.text).toBe('Not found') + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name', 'def') + }) + }) + + describe('get value', () => { + test('no value', async () => { + const { app, registry } = createService() + + const mockFn = registry.instance.variable.custom.getValue + mockFn.mockReturnValue(undefined) + + // Perform the request + const res = await supertest(app).get('/api/custom-variable/my-var-name/value').send() + expect(res.status).toBe(404) + expect(res.text).toBe('Not found') + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name') + }) + + test('value empty string', async () => { + const { app, registry } = createService() + + const mockFn = registry.instance.variable.custom.getValue + mockFn.mockReturnValue('') + + // Perform the request + const res = await supertest(app).get('/api/custom-variable/my-var-name/value').send() + expect(res.status).toBe(200) + expect(res.text).toBe('') + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name') + }) + + test('value proper string', async () => { + const { app, registry } = createService() + + const mockFn = registry.instance.variable.custom.getValue + mockFn.mockReturnValue('something 123') + + // Perform the request + const res = await supertest(app).get('/api/custom-variable/my-var-name/value').send() + expect(res.status).toBe(200) + expect(res.text).toBe('something 123') + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name') + }) + + test('value zero number', async () => { + const { app, registry } = createService() + + const mockFn = registry.instance.variable.custom.getValue + mockFn.mockReturnValue(0) + + // Perform the request + const res = await supertest(app).get('/api/custom-variable/my-var-name/value').send() + expect(res.status).toBe(200) + expect(res.text).toBe('0') + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name') + }) + + test('value real number', async () => { + const { app, registry } = createService() + + const mockFn = registry.instance.variable.custom.getValue + mockFn.mockReturnValue(455.8) + + // Perform the request + const res = await supertest(app).get('/api/custom-variable/my-var-name/value').send() + expect(res.status).toBe(200) + expect(res.text).toBe('455.8') + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name') + }) + + test('value false', async () => { + const { app, registry } = createService() + + const mockFn = registry.instance.variable.custom.getValue + mockFn.mockReturnValue(false) + + // Perform the request + const res = await supertest(app).get('/api/custom-variable/my-var-name/value').send() + expect(res.status).toBe(200) + expect(res.text).toBe('false') + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name') + }) + + test('value true', async () => { + const { app, registry } = createService() + + const mockFn = registry.instance.variable.custom.getValue + mockFn.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).get('/api/custom-variable/my-var-name/value').send() + expect(res.status).toBe(200) + expect(res.text).toBe('true') + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name') + }) + + test('value object', async () => { + const { app, registry } = createService() + + const mockFn = registry.instance.variable.custom.getValue + mockFn.mockReturnValue({ + a: 1, + b: 'str', + }) + + // Perform the request + const res = await supertest(app).get('/api/custom-variable/my-var-name/value').send() + expect(res.status).toBe(200) + expect(res.text).toBe('{"a":1,"b":"str"}') + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name') + }) + }) + }) + + describe('controls by location', () => { + describe('down', () => { + test('no control', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/down').send() + expect(res.status).toBe(204) + // expect(res.text).toBe('No control') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/down').send() + expect(res.status).toBe(200) + expect(res.text).toBe('ok') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(1) + expect(registry.controls.pressControl).toHaveBeenCalledWith('control123', true, 'http') + }) + + test('bad page', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1a/2/3/down').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2a/3/down').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3a/down').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('up', () => { + test('no control', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/up').send() + expect(res.status).toBe(204) + // expect(res.text).toBe('No control') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/up').send() + expect(res.status).toBe(200) + expect(res.text).toBe('ok') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(1) + expect(registry.controls.pressControl).toHaveBeenCalledWith('control123', false, 'http') + }) + + test('bad page', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1a/2/3/up').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2a/3/up').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3a/up').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('press', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + test('no control', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/press').send() + expect(res.status).toBe(204) + // expect(res.text).toBe('No control') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/press').send() + expect(res.status).toBe(200) + expect(res.text).toBe('ok') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(1) + expect(registry.controls.pressControl).toHaveBeenCalledWith('control123', true, 'http') + + jest.advanceTimersByTime(100) + + expect(registry.controls.pressControl).toHaveBeenCalledTimes(2) + expect(registry.controls.pressControl).toHaveBeenLastCalledWith('control123', false, 'http') + }) + + test('bad page', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1a/2/3/press').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2a/3/press').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3a/press').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('rotate left', () => { + test('no control', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/rotate-left').send() + expect(res.status).toBe(204) + // expect(res.text).toBe('No control') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/rotate-left').send() + expect(res.status).toBe(200) + expect(res.text).toBe('ok') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(1) + expect(registry.controls.rotateControl).toHaveBeenCalledWith('control123', false, 'http') + }) + + test('bad page', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1a/2/3/rotate-left').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2a/3/rotate-left').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3a/rotate-left').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('rotate right', () => { + test('no control', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/rotate-right').send() + expect(res.status).toBe(204) + // expect(res.text).toBe('No control') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/rotate-right').send() + expect(res.status).toBe(200) + expect(res.text).toBe('ok') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(1) + expect(registry.controls.rotateControl).toHaveBeenCalledWith('control123', true, 'http') + }) + + test('bad page', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1a/2/3/rotate-right').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2a/3/rotate-right').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3a/rotate-right').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('set step', () => { + test('no control', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/step?step=2') + expect(res.status).toBe(204) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.getControl).toHaveBeenCalledTimes(0) + }) + + test('no payload', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('test') + + const mockControl = mock( + { + stepMakeCurrent: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/step') + expect(res.status).toBe(400) + expect(res.text).toBe('Bad step') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.getControl).toHaveBeenCalledTimes(1) + expect(registry.controls.getControl).toHaveBeenCalledWith('test') + + expect(mockControl.stepMakeCurrent).toHaveBeenCalledTimes(1) + expect(mockControl.stepMakeCurrent).toHaveBeenCalledWith(NaN) + }) + + test('ok', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + + const mockControl = mock( + { + stepMakeCurrent: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + mockControl.stepMakeCurrent.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/step?step=2') + expect(res.status).toBe(200) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(mockControl.stepMakeCurrent).toHaveBeenCalledTimes(1) + expect(mockControl.stepMakeCurrent).toHaveBeenCalledWith(2) + }) + + test('bad page', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1a/2/3/step').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2a/3/step').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3a/step').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('set style', () => { + test('no control', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/style').send() + expect(res.status).toBe(204) + // expect(res.text).toBe('No control') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + }) + + test('control without style', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('abc') + + registry.controls.getControl.mockReturnValue({ abc: null }) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3/style').send() + expect(res.status).toBe(204) + // expect(res.text).toBe('No control') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + + expect(registry.controls.getControl).toHaveBeenCalledTimes(1) + expect(registry.controls.getControl).toHaveBeenCalledWith('abc') + }) + + test('bad page', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1a/2/3/style').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2a/3/style').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + const res = await supertest(app).post('/api/location/1/2/3a/style').send() + expect(res.status).toBe(404) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + async function testSetStyle(queryStr, body, expected) { + const { app, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('abc') + + const mockControl = mock( + { + styleSetFields: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + const res = await supertest(app) + .post(`/api/location/1/2/3/style?${queryStr}`) + .set('Content-Type', 'application/json') + .send(body) + expect(res.status).toBe(200) + expect(res.text).toBe('ok') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + + expect(registry.controls.getControl).toHaveBeenCalledTimes(1) + expect(registry.controls.getControl).toHaveBeenCalledWith('abc') + + if (expected) { + expect(mockControl.styleSetFields).toHaveBeenCalledTimes(1) + expect(mockControl.styleSetFields).toHaveBeenCalledWith(expected) + } else { + expect(mockControl.styleSetFields).toHaveBeenCalledTimes(0) + } + } + + test('set style without properties', async () => { + await testSetStyle('', undefined, null) + }) + + test('set style unknown properties', async () => { + await testSetStyle('abc=123', { def: 456 }, null) + }) + + test('set color properties', async () => { + await testSetStyle( + 'bgcolor=%23abcdef', + { color: 'rgb(1,2,3)' }, + { + bgcolor: rgb('ab', 'cd', 'ef', 16), + color: rgb(1, 2, 3), + } + ) + }) + + test('set color properties bad', async () => { + await testSetStyle('bgcolor=bad', { color: 'rgb(1,2,an)' }, null) + }) + + test('set text size auto', async () => { + await testSetStyle('', { size: 'auto' }, { size: 'auto' }) + }) + + test('set text size bad', async () => { + await testSetStyle('', { size: 'bad' }, null) + }) + + test('set text size number', async () => { + await testSetStyle('size=134.2', {}, { size: 134 }) + }) + + test('set text', async () => { + await testSetStyle('text=something%20%23%20new', {}, { text: 'something # new' }) + }) + + test('set empty text', async () => { + await testSetStyle('text=', {}, { text: '' }) + await testSetStyle('', { text: '' }, { text: '' }) + }) + + test('set empty png', async () => { + await testSetStyle('png64=', {}, { png64: null }) + await testSetStyle('', { png64: '' }, { png64: null }) + }) + + test('set bad png', async () => { + await testSetStyle('', { png64: 'something' }, null) + }) + + test('set png', async () => { + await testSetStyle('', { png64: 'data:image/png;base64,aaabncc' }, { png64: 'data:image/png;base64,aaabncc' }) + }) + + test('set bad alignment', async () => { + await testSetStyle('', { alignment: 'something' }, { alignment: 'center:center' }) + await testSetStyle('', { alignment: 'top:nope' }, { alignment: 'center:center' }) + }) + + test('set alignment', async () => { + await testSetStyle('', { alignment: 'left:top' }, { alignment: 'left:top' }) + }) + + test('set bad pngalignment', async () => { + await testSetStyle('', { pngalignment: 'something' }, { pngalignment: 'center:center' }) + await testSetStyle('', { pngalignment: 'top:nope' }, { pngalignment: 'center:center' }) + }) + + test('set pngalignment', async () => { + await testSetStyle('', { pngalignment: 'left:top' }, { pngalignment: 'left:top' }) + }) + }) + }) +}) diff --git a/test/Service/OscApi.test.js b/test/Service/OscApi.test.js new file mode 100644 index 0000000000..bdeac3cb14 --- /dev/null +++ b/test/Service/OscApi.test.js @@ -0,0 +1,815 @@ +import { jest } from '@jest/globals' +import { mock } from 'jest-mock-extended' +import { ServiceOscApi } from '../../lib/Service/OscApi' +import { rgb } from '../../lib/Resources/Util' + +const mockOptions = { + fallbackMockImplementation: () => { + throw new Error('not mocked') + }, +} + +describe('OscApi', () => { + function createService() { + const logger = mock( + { + info: jest.fn(), + debug: jest.fn(), + }, + mockOptions + ) + const logController = mock( + { + createLogger: () => logger, + }, + mockOptions + ) + const registry = mock( + { + log: logController, + surfaces: mock({}, mockOptions), + page: mock({}, mockOptions), + controls: mock({}, mockOptions), + instance: mock( + { + variable: mock( + { + custom: mock({}, mockOptions), + }, + mockOptions + ), + }, + mockOptions + ), + }, + mockOptions + ) + + const service = new ServiceOscApi(registry) + const router = service.router + + return { + registry, + router, + service, + logger, + } + } + + describe('surfaces', () => { + describe('rescan', () => { + test('ok', async () => { + const { router, registry } = createService() + registry.surfaces.triggerRefreshDevices.mockResolvedValue() + + // Perform the request + router.processMessage('/surfaces/rescan') + + expect(registry.surfaces.triggerRefreshDevices).toHaveBeenCalledTimes(1) + }) + + test('failed', async () => { + const { router, registry } = createService() + registry.surfaces.triggerRefreshDevices.mockRejectedValue('internal error') + + // Perform the request + router.processMessage('/surfaces/rescan') + + expect(registry.surfaces.triggerRefreshDevices).toHaveBeenCalledTimes(1) + }) + }) + }) + + describe('custom-variable', () => { + describe('set value', () => { + test('no value', async () => { + const { router } = createService() + + // Perform the request + router.processMessage('/custom-variable/my-var-name/value', { args: [] }) + }) + + test('ok from query', async () => { + const { router, registry } = createService() + + const mockFn = registry.instance.variable.custom.setValue + mockFn.mockReturnValue() + + // Perform the request + router.processMessage('/custom-variable/my-var-name/value', { + args: [ + { + value: '123', + }, + ], + }) + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name', '123') + }) + + test('ok from body', async () => { + const { router, registry } = createService() + + const mockFn = registry.instance.variable.custom.setValue + mockFn.mockReturnValue() + + // Perform the request + router.processMessage('/custom-variable/my-var-name/value', { + args: [ + { + value: 'def', + }, + ], + }) + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name', 'def') + }) + + test('unknown name', async () => { + const { router, registry } = createService() + + const mockFn = registry.instance.variable.custom.setValue + mockFn.mockReturnValue('Unknown name') + + // Perform the request + router.processMessage('/custom-variable/my-var-name/value', { + args: [ + { + value: 'def', + }, + ], + }) + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name', 'def') + }) + }) + }) + + describe('controls by location', () => { + describe('down', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage('/location/1/2/3/down') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.getControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2/3/down') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(1) + expect(registry.controls.pressControl).toHaveBeenCalledWith('control123', true, 'osc') + }) + + test('bad page', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1a/2/3/down') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2a/3/down') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2/3a/down') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('up', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage('/location/1/2/3/up') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.getControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2/3/up') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(1) + expect(registry.controls.pressControl).toHaveBeenCalledWith('control123', false, 'osc') + }) + + test('bad page', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1a/2/3/up') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2a/3/up') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2/3a/up') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('press', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + router.processMessage('/location/1/2/3/press') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2/3/press') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(1) + expect(registry.controls.pressControl).toHaveBeenCalledWith('control123', true, 'osc') + + jest.advanceTimersByTime(100) + + expect(registry.controls.pressControl).toHaveBeenCalledTimes(2) + expect(registry.controls.pressControl).toHaveBeenLastCalledWith('control123', false, 'osc') + }) + + test('bad page', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1a/2/3/press') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2a/3/press') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2/3a/press') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('rotate left', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + router.processMessage('/location/1/2/3/rotate-left') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2/3/rotate-left') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(1) + expect(registry.controls.rotateControl).toHaveBeenCalledWith('control123', false, 'osc') + }) + + test('bad page', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1a/2/3/rotate-left') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2a/3/rotate-left') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2/3a/rotate-left') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('rotate right', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + router.processMessage('/location/1/2/3/rotate-right') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2/3/rotate-right') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(1) + expect(registry.controls.rotateControl).toHaveBeenCalledWith('control123', true, 'osc') + }) + + test('bad page', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1a/2/3/rotate-right') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2a/3/rotate-right') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2/3a/rotate-right') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('set step', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage('/location/1/2/3/step', { args: [{ value: 2 }] }) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.getControl).toHaveBeenCalledTimes(0) + }) + + test('no payload', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('test') + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage('/location/1/2/3/step', { args: [] }) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.getControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + + const mockControl = mock( + { + stepMakeCurrent: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + mockControl.stepMakeCurrent.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2/3/step', { args: [{ value: 2 }] }) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(mockControl.stepMakeCurrent).toHaveBeenCalledTimes(1) + expect(mockControl.stepMakeCurrent).toHaveBeenCalledWith(2) + }) + + test('string step', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + + const mockControl = mock( + { + stepMakeCurrent: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + mockControl.stepMakeCurrent.mockReturnValue(true) + + // Perform the request + router.processMessage('/location/1/2/3/step', { args: [{ value: '4' }] }) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(mockControl.stepMakeCurrent).toHaveBeenCalledTimes(1) + expect(mockControl.stepMakeCurrent).toHaveBeenCalledWith(4) + }) + + test('bad page', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage('/location/1a/2/3/step', { args: [{ value: 2 }] }) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage('/location/1/2a/3/step', { args: [{ value: 2 }] }) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage('/location/1/2/3a/step', { args: [{ value: 2 }] }) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + }) + }) + + describe('set style: text', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + router.processMessage('/location/1/2/3/style/text', { args: [{ value: 'abc' }] }) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('abc') + + const mockControl = mock( + { + styleSetFields: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage('/location/1/2/3/style/text', { args: [{ value: 'def' }] }) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + + expect(registry.controls.getControl).toHaveBeenCalledTimes(1) + expect(registry.controls.getControl).toHaveBeenCalledWith('abc') + + expect(mockControl.styleSetFields).toHaveBeenCalledTimes(1) + expect(mockControl.styleSetFields).toHaveBeenCalledWith({ text: 'def' }) + }) + }) + + describe('set style: color', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + router.processMessage('/location/1/2/3/style/color', { args: [{ value: 'abc' }] }) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + }) + + async function runColor(args, expected) { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('abc') + + const mockControl = mock( + { + styleSetFields: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage('/location/1/2/3/style/color', { args }) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + + expect(registry.controls.getControl).toHaveBeenCalledTimes(1) + expect(registry.controls.getControl).toHaveBeenCalledWith('abc') + + expect(mockControl.styleSetFields).toHaveBeenCalledTimes(1) + expect(mockControl.styleSetFields).toHaveBeenCalledWith({ color: expected }) + } + + test('ok hex', async () => { + await runColor([{ value: '#abcdef' }], rgb('ab', 'cd', 'ef', 16)) + }) + + test('ok separate', async () => { + await runColor([{ value: 5 }, { value: 8 }, { value: 11 }], rgb(5, 8, 11)) + }) + + test('ok css', async () => { + await runColor([{ value: 'rgb(1,4,5)' }], rgb(1, 4, 5)) + }) + }) + + describe('set style: bgcolor', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + router.processMessage('/location/1/2/3/style/bgcolor', { args: [{ value: 'abc' }] }) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + }) + + async function runColor(args, expected) { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('abc') + + const mockControl = mock( + { + styleSetFields: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage('/location/1/2/3/style/bgcolor', { args }) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + + expect(registry.controls.getControl).toHaveBeenCalledTimes(1) + expect(registry.controls.getControl).toHaveBeenCalledWith('abc') + + expect(mockControl.styleSetFields).toHaveBeenCalledTimes(1) + expect(mockControl.styleSetFields).toHaveBeenCalledWith({ bgcolor: expected }) + } + + test('ok hex', async () => { + await runColor([{ value: '#abcdef' }], rgb('ab', 'cd', 'ef', 16)) + }) + + test('ok separate', async () => { + await runColor([{ value: 5 }, { value: 8 }, { value: 11 }], rgb(5, 8, 11)) + }) + + test('ok css', async () => { + await runColor([{ value: 'rgb(1,4,5)' }], rgb(1, 4, 5)) + }) + }) + }) +}) diff --git a/test/Service/Rosstalk.test.js b/test/Service/Rosstalk.test.js new file mode 100644 index 0000000000..6aeb6c69fb --- /dev/null +++ b/test/Service/Rosstalk.test.js @@ -0,0 +1,109 @@ +import { jest } from '@jest/globals' +import { mock } from 'jest-mock-extended' +import ServiceRosstalk from '../../lib/Service/Rosstalk' + +const mockOptions = { + fallbackMockImplementation: () => { + throw new Error('not mocked') + }, +} + +describe('Rosstalk', () => { + function createService() { + const logger = mock( + { + info: jest.fn(), + debug: jest.fn(), + }, + mockOptions + ) + const logController = mock( + { + createLogger: () => logger, + }, + mockOptions + ) + const registry = mock( + { + log: logController, + page: mock( + { + getControlIdAt: jest.fn(), + }, + mockOptions + ), + controls: mock( + { + pressControl: jest.fn(), + }, + mockOptions + ), + userconfig: { + // Force config to return true + getKey: () => false, + }, + }, + mockOptions + ) + + const service = new ServiceRosstalk(registry) + + return { + registry, + service, + logger, + } + } + + describe('CC - bank', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + test('no control', async () => { + const { registry, service } = createService() + + service.processIncoming(null, 'CC 12:24') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenLastCalledWith({ + pageNumber: 12, + row: 2, + column: 7, + }) + + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('out of range', async () => { + const { registry, service } = createService() + + service.processIncoming(null, 'CC 12:34') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { registry, service } = createService() + registry.page.getControlIdAt.mockReturnValue('myControl') + + service.processIncoming(null, 'CC 12:24') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenLastCalledWith({ + pageNumber: 12, + row: 2, + column: 7, + }) + + expect(registry.controls.pressControl).toHaveBeenCalledTimes(1) + expect(registry.controls.pressControl).toHaveBeenLastCalledWith('myControl', true, 'rosstalk') + + jest.advanceTimersByTime(100) + + expect(registry.controls.pressControl).toHaveBeenCalledTimes(2) + expect(registry.controls.pressControl).toHaveBeenLastCalledWith('myControl', false, 'rosstalk') + }) + }) +}) diff --git a/test/Service/TcpUdpApi.test.js b/test/Service/TcpUdpApi.test.js new file mode 100644 index 0000000000..e8b1ff4bfa --- /dev/null +++ b/test/Service/TcpUdpApi.test.js @@ -0,0 +1,790 @@ +import { jest } from '@jest/globals' +import { mock } from 'jest-mock-extended' +import { ApiMessageError, ServiceTcpUdpApi } from '../../lib/Service/TcpUdpApi' +import { rgb } from '../../lib/Resources/Util' + +const mockOptions = { + fallbackMockImplementation: () => { + throw new Error('not mocked') + }, +} + +describe('TcpUdpApi', () => { + function createService() { + const logger = mock( + { + info: jest.fn(), + debug: jest.fn(), + }, + mockOptions + ) + const logController = mock( + { + createLogger: () => logger, + }, + mockOptions + ) + const registry = mock( + { + log: logController, + surfaces: mock({}, mockOptions), + page: mock({}, mockOptions), + controls: mock({}, mockOptions), + instance: mock( + { + variable: mock( + { + custom: mock({}, mockOptions), + }, + mockOptions + ), + }, + mockOptions + ), + }, + mockOptions + ) + + const service = new ServiceTcpUdpApi(registry, 'fake-proto', null) + const router = service.router + + return { + registry, + router, + service, + logger, + } + } + + describe('surfaces', () => { + describe('rescan', () => { + test('ok', async () => { + const { router, registry } = createService() + registry.surfaces.triggerRefreshDevices.mockResolvedValue() + + // Perform the request + await router.processMessage('surfaces rescan') + + expect(registry.surfaces.triggerRefreshDevices).toHaveBeenCalledTimes(1) + }) + + test('failed', async () => { + const { router, registry } = createService() + registry.surfaces.triggerRefreshDevices.mockRejectedValue('internal error') + + // Perform the request + await expect(router.processMessage('surfaces rescan')).rejects.toEqual(new ApiMessageError('Scan failed')) + + expect(registry.surfaces.triggerRefreshDevices).toHaveBeenCalledTimes(1) + }) + }) + }) + + describe('custom-variable', () => { + describe('set value', () => { + test('ok from query', async () => { + const { router, registry } = createService() + + const mockFn = registry.instance.variable.custom.setValue + mockFn.mockReturnValue() + + // Perform the request + await router.processMessage('custom-variable my-var-name set-value 123') + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name', '123') + }) + + test('ok empty', async () => { + const { router, registry } = createService() + + const mockFn = registry.instance.variable.custom.setValue + mockFn.mockReturnValue() + + // Perform the request + await router.processMessage('custom-variable my-var-name set-value ') + + expect(mockFn).toHaveBeenCalledTimes(1) + expect(mockFn).toHaveBeenCalledWith('my-var-name', '') + }) + }) + }) + + describe('controls by location', () => { + describe('down', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + expect(() => router.processMessage('location 1/2/3 down')).toThrow( + new ApiMessageError('No control at location') + ) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.getControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('location 1/2/3 down') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(1) + expect(registry.controls.pressControl).toHaveBeenCalledWith('control123', true, 'fake-proto') + }) + + test('bad page', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1a/2/3 down')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1/2a/3 down')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1/2/3a down')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('up', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + expect(() => router.processMessage('location 1/2/3 up')).toThrow(new ApiMessageError('No control at location')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.getControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('location 1/2/3 up') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(1) + expect(registry.controls.pressControl).toHaveBeenCalledWith('control123', false, 'fake-proto') + }) + + test('bad page', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1a/2/3 up')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1/2a/3 up')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1/2/3a up')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('press', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + expect(() => router.processMessage('location 1/2/3 press')).toThrow( + new ApiMessageError('No control at location') + ) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + router.processMessage('location 1/2/3 press') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(1) + expect(registry.controls.pressControl).toHaveBeenCalledWith('control123', true, 'fake-proto') + + jest.advanceTimersByTime(100) + + expect(registry.controls.pressControl).toHaveBeenCalledTimes(2) + expect(registry.controls.pressControl).toHaveBeenLastCalledWith('control123', false, 'fake-proto') + }) + + test('bad page', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1a/2/3 press')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1/2a/3 press')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.pressControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1/2/3a press')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('rotate left', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + expect(() => router.processMessage('location 1/2/3 rotate-left')).toThrow( + new ApiMessageError('No control at location') + ) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + router.processMessage('location 1/2/3 rotate-left') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(1) + expect(registry.controls.rotateControl).toHaveBeenCalledWith('control123', false, 'fake-proto') + }) + + test('bad page', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1a/2/3 rotate-left')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1/2a/3 rotate-left')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1/2/3a rotate-left')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('rotate right', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + expect(() => router.processMessage('location 1/2/3 rotate-right')).toThrow( + new ApiMessageError('No control at location') + ) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.pressControl).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + router.processMessage('location 1/2/3 rotate-right') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(1) + expect(registry.controls.rotateControl).toHaveBeenCalledWith('control123', true, 'fake-proto') + }) + + test('bad page', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1a/2/3 rotate-right')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1/2a/3 rotate-right')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + registry.controls.rotateControl.mockReturnValue(true) + + // Perform the request + expect(() => router.processMessage('location 1/2/3a rotate-right')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.rotateControl).toHaveBeenCalledTimes(0) + }) + }) + + describe('set step', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + expect(() => router.processMessage('location 1/2/3 set-step 2')).toThrow( + new ApiMessageError('No control at location') + ) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(registry.controls.getControl).toHaveBeenCalledTimes(0) + }) + + test('no payload', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('test') + + const mockControl = mock( + { + stepMakeCurrent: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + expect(() => router.processMessage('location 1/2/3 step')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + expect(registry.controls.getControl).toHaveBeenCalledTimes(0) + expect(mockControl.stepMakeCurrent).toHaveBeenCalledTimes(0) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + + const mockControl = mock( + { + stepMakeCurrent: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + mockControl.stepMakeCurrent.mockReturnValue(true) + + // Perform the request + router.processMessage('location 1/2/3 set-step 2') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + expect(mockControl.stepMakeCurrent).toHaveBeenCalledTimes(1) + expect(mockControl.stepMakeCurrent).toHaveBeenCalledWith(2) + }) + + test('bad page', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + expect(() => router.processMessage('location 1a/2/3 set-step 2')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + }) + + test('bad row', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + expect(() => router.processMessage('location 1/2a/3 set-step 2')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + }) + + test('bad column', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('control123') + + const mockControl = mock({}, mockOptions) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + expect(() => router.processMessage('location 1/2/3a set-step 2')).toThrow(new ApiMessageError('Syntax error')) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(0) + }) + }) + + describe('set style: text', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + expect(() => router.processMessage('location 1/2/3 style text abc')).toThrow( + new ApiMessageError('No control at location') + ) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + }) + + test('ok', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('abc') + + const mockControl = mock( + { + styleSetFields: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage('location 1/2/3 style text def') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + + expect(registry.controls.getControl).toHaveBeenCalledTimes(1) + expect(registry.controls.getControl).toHaveBeenCalledWith('abc') + + expect(mockControl.styleSetFields).toHaveBeenCalledTimes(1) + expect(mockControl.styleSetFields).toHaveBeenCalledWith({ text: 'def' }) + }) + + test('ok no text', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('abc') + + const mockControl = mock( + { + styleSetFields: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage('location 1/2/3 style text') + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + + expect(registry.controls.getControl).toHaveBeenCalledTimes(1) + expect(registry.controls.getControl).toHaveBeenCalledWith('abc') + + expect(mockControl.styleSetFields).toHaveBeenCalledTimes(1) + expect(mockControl.styleSetFields).toHaveBeenCalledWith({ text: '' }) + }) + }) + + describe('set style: color', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + expect(() => router.processMessage('location 1/2/3 style color abc')).toThrow( + new ApiMessageError('No control at location') + ) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + }) + + async function runColor(input, expected) { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('abc') + + const mockControl = mock( + { + styleSetFields: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage(`location 1/2/3 style color ${input}`) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + + expect(registry.controls.getControl).toHaveBeenCalledTimes(1) + expect(registry.controls.getControl).toHaveBeenCalledWith('abc') + + expect(mockControl.styleSetFields).toHaveBeenCalledTimes(1) + expect(mockControl.styleSetFields).toHaveBeenCalledWith({ color: expected }) + } + + test('ok hex', async () => { + await runColor('#abcdef', rgb('ab', 'cd', 'ef', 16)) + }) + + test('ok css', async () => { + await runColor('rgb(1,4,5)', rgb(1, 4, 5)) + }) + }) + + describe('set style: bgcolor', () => { + test('no control', async () => { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue(undefined) + + // Perform the request + expect(() => router.processMessage('location 1/2/3 style bgcolor abc')).toThrow( + new ApiMessageError('No control at location') + ) + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + }) + + async function runColor(input, expected) { + const { router, registry } = createService() + registry.page.getControlIdAt.mockReturnValue('abc') + + const mockControl = mock( + { + styleSetFields: jest.fn(), + }, + mockOptions + ) + registry.controls.getControl.mockReturnValue(mockControl) + + // Perform the request + router.processMessage(`location 1/2/3 style bgcolor ${input}`) + + expect(registry.page.getControlIdAt).toHaveBeenCalledTimes(1) + expect(registry.page.getControlIdAt).toHaveBeenCalledWith({ + pageNumber: 1, + row: 2, + column: 3, + }) + + expect(registry.controls.getControl).toHaveBeenCalledTimes(1) + expect(registry.controls.getControl).toHaveBeenCalledWith('abc') + + expect(mockControl.styleSetFields).toHaveBeenCalledTimes(1) + expect(mockControl.styleSetFields).toHaveBeenCalledWith({ bgcolor: expected }) + } + + test('ok hex', async () => { + await runColor('#abcdef', rgb('ab', 'cd', 'ef', 16)) + }) + + test('ok css', async () => { + await runColor('rgb(1,4,5)', rgb(1, 4, 5)) + }) + }) + }) +}) diff --git a/tools/dev.mjs b/tools/dev.mjs index 40367051f2..5bbba6c353 100644 --- a/tools/dev.mjs +++ b/tools/dev.mjs @@ -14,7 +14,7 @@ const cachedDebounces = {} chokidar .watch(['**/*.mjs', '**/*.js', '**/*.cjs', '**/*.json'], { ignoreInitial: true, - ignored: ['**/node_modules/**', './webui/', './launcher/', './dist/'], + ignored: ['**/node_modules/**', './webui/', './launcher/', './dist/', './test/'], }) .on('all', (event, filename) => { const fullpath = path.resolve(filename) diff --git a/webui/src/UserConfig/HttpConfig.jsx b/webui/src/UserConfig/HttpConfig.jsx new file mode 100644 index 0000000000..2de10c930f --- /dev/null +++ b/webui/src/UserConfig/HttpConfig.jsx @@ -0,0 +1,57 @@ +import React from 'react' +import { CButton, CInput } from '@coreui/react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faUndo } from '@fortawesome/free-solid-svg-icons' +import CSwitch from '../CSwitch' + +export function HttpConfig({ config, setValue, resetValue }) { + return ( + <> + + + HTTP + + + + HTTP API + +
+ setValue('http_api_enabled', e.currentTarget.checked)} + /> +
+ + + resetValue('http_api_enabled')} title="Reset to default"> + + + + + + + Deprecated HTTP API +
+ (This portion of the API will be removed in a future release) + + +
+ setValue('http_legacy_api_enabled', e.currentTarget.checked)} + /> +
+ + + resetValue('http_legacy_api_enabled')} title="Reset to default"> + + + + + + ) +} diff --git a/webui/src/UserConfig/HttpProtocol.jsx b/webui/src/UserConfig/HttpProtocol.jsx index 0ae148d529..c71f1bbe9c 100644 --- a/webui/src/UserConfig/HttpProtocol.jsx +++ b/webui/src/UserConfig/HttpProtocol.jsx @@ -7,12 +7,63 @@ export function HttpProtocol() {

Commands:

+

+ This API tries to follow REST principles, and the convention that a POST request will modify a + value, and a GET request will retrieve values. +

  • - /press/bank/<page>/<button> + Press and release a button (run both down and up actions)
    - Press and release a button (run both down and up actions) + Method: POST +
    + Path: /api/location/<page>/<row>/<column> + /press +
  • +
  • + Press the button (run down actions and hold) +
    + Method: POST +
    + Path: /api/location/<page>/ + <row>/<column> + /down +
  • +
  • + Release the button (run up actions) +
    + Method: POST +
    + Path: /api/location/<page>/<row>/<column> + /up +
  • +
  • + Trigger a left rotation of the button/encoder +
    + Method: POST +
    + Path: /api/location/<page>/<row>/<column> + /rotate-left +
  • +
  • + Trigger a right rotation of the button/encoder +
    + Method: POST +
    + Path: /api/location/<page>/<row>/<column> + /rotate-right
  • +
  • + Set the current step of a button/encoder +
    + Method: POST +
    + Path: /api/location/<page>/<row>/<column> + /step?step=<step> +
  • + +
    +
  • /style/bank/<page>/<button> ?bgcolor=<bgcolor HEX> @@ -37,13 +88,19 @@ export function HttpProtocol() {
    Change text size on a button (between the predefined values)
  • +
  • - /set/custom-variable/<name>?value=<value> + POST /api/custom-variable/<name>/value?value=<value>
    Change custom variable value
  • - /rescan + POST /api/custom-variable/<name>/value <value> +
    + Change custom variable value +
  • +
  • + POST /surfaces/rescan
    Make Companion rescan for newly attached USB surfaces
  • @@ -77,6 +134,55 @@ export function HttpProtocol() {
    /set/custom-variable/cue?value=intro

    + +

    + Deprecated Commands: +

    +

    + The following commands are deprecated and have replacements listed above. They will be removed in a future + version of Companion. +

    +
      +
    • + /press/bank/<page>/<button> +
      + Press and release a button (run both down and up actions) +
    • +
    • + /style/bank/<page>/<button> + ?bgcolor=<bgcolor HEX> +
      + Change background color of button +
    • +
    • + /style/bank/<page>/<button> + ?color=<color HEX> +
      + Change color of text on button +
    • +
    • + /style/bank/<page>/<button> + ?text=<text> +
      + Change text on a button +
    • +
    • + /style/bank/<page>/<button> + ?size=<text size> +
      + Change text size on a button (between the predefined values) +
    • +
    • + /set/custom-variable/<name>?value=<value> +
      + Change custom variable value +
    • +
    • + /rescan +
      + Make Companion rescan for newly attached USB surfaces +
    • +
    ) } diff --git a/webui/src/UserConfig/OscConfig.jsx b/webui/src/UserConfig/OscConfig.jsx index 69e2e85f2e..e1cb3ad39a 100644 --- a/webui/src/UserConfig/OscConfig.jsx +++ b/webui/src/UserConfig/OscConfig.jsx @@ -47,6 +47,28 @@ export function OscConfig({ config, setValue, resetValue }) { + + + Deprecated OSC API +
    + (This portion of the API will be removed in a future release) + + +
    + setValue('osc_legacy_api_enabled', e.currentTarget.checked)} + /> +
    + + + resetValue('osc_legacy_api_enabled')} title="Reset to default"> + + + + ) } diff --git a/webui/src/UserConfig/OscProtocol.jsx b/webui/src/UserConfig/OscProtocol.jsx index e2854f2bc4..5c1f44d673 100644 --- a/webui/src/UserConfig/OscProtocol.jsx +++ b/webui/src/UserConfig/OscProtocol.jsx @@ -20,44 +20,77 @@ export function OscProtocol() {

    • - /press/bank/<page>/<button> + /location/<page>/<row>/<column>/press
      Press and release a button (run both down and up actions)
    • - /press/bank/<page>/<button> <1> + /location/<page>/<row>/<column>/down
      Press the button (run down actions and hold)
    • - /press/bank/<page>/<button> <0> + /location/<page>/<row>/<column>/up
      Release the button (run up actions)
    • - /style/bgcolor/<page>/<button> <red 0-255> <green 0-255> - <blue 0-255> + /location/<page>/<row>/<column> + /rotate-left +
      + Trigger a left rotation of the button/encoder +
    • +
    • + /location/<page>/<row>/<column> + /rotate-right +
      + Trigger a right rotation of the button/encoder +
    • +
    • + /location/<page>/<row>/<column> + /step +
      + Set the current step of a button/encoder +
    • + +
    • + /location/<page>/<row>/<column> + /style/bgcolor <red 0-255> <green 0-255> <blue 0-255>
      Change background color of button
    • - /style/color/<page>/<button> <red 0-255> <green 0-255> - <blue 0-255> + /location/<page>/<row>/<column> + /style/bgcolor <css color> +
      + Change background color of button +
    • +
    • + /location/<page>/<row>/<column> + /style/color <red 0-255> <green 0-255> <blue 0-255>
      Change color of text on button
    • - /style/text/<page>/<button> <text> + /location/<page>/<row>/<column> + /style/color <css color> +
      + Change color of text on button +
    • +
    • + /location/<page>/<row>/<column> + /style/text <text>
      Change text on a button
    • +
    • /custom-variable/<name>/value <value>
      Change custom variable value
    • - /rescan 1 + /surfaces/rescan
      Make Companion rescan for newly attached USB surfaces
    • @@ -68,21 +101,25 @@ export function OscProtocol() {

      - Press button 5 on page 1 down and hold + Press row 0, column 5 on page 1 down and hold
      - /press/bank/1/5 1 + /location/1/0/5/press

      - Change button background color of button 5 on page 1 to red + Change button background color of row 0, column 5 on page 1 to red
      - /style/bgcolor/1/5 255 0 0 + /location/1/0/5/style/bgcolor 255 0 0 +
      + /location/1/0/5/style/bgcolor rgb(255,0,0) +
      + /location/1/0/5/style/bgcolor #ff0000

      - Change the text of button 5 on page 1 to ONLINE + Change the text of row 0, column 5 on page 1 to ONLINE
      - /style/text/1/5 ONLINE + /location/1/0/5/style/text ONLINE

      @@ -90,6 +127,53 @@ export function OscProtocol() {
      /custom-variable/cue/value intro

      + +

      + Deprecated Commands: +

      +

      + The following commands are deprecated and have replacements listed above. They will be removed in a future + version of Companion. +

      +
        +
      • + /press/bank/<page>/<button> +
        + Press and release a button (run both down and up actions) +
      • +
      • + /press/bank/<page>/<button> <1> +
        + Press the button (run down actions and hold) +
      • +
      • + /press/bank/<page>/<button> <0> +
        + Release the button (run up actions) +
      • +
      • + /style/bgcolor/<page>/<button> <red 0-255> <green 0-255> + <blue 0-255> +
        + Change background color of button +
      • +
      • + /style/color/<page>/<button> <red 0-255> <green 0-255> + <blue 0-255> +
        + Change color of text on button +
      • +
      • + /style/text/<page>/<button> <text> +
        + Change text on a button +
      • +
      • + /rescan 1 +
        + Make Companion rescan for newly attached USB surfaces +
      • +
      ) } diff --git a/webui/src/UserConfig/TcpConfig.jsx b/webui/src/UserConfig/TcpConfig.jsx index f03bfc4f0b..0a222bb5bb 100644 --- a/webui/src/UserConfig/TcpConfig.jsx +++ b/webui/src/UserConfig/TcpConfig.jsx @@ -47,6 +47,28 @@ export function TcpConfig({ config, setValue, resetValue }) { + + + Deprecated TCP API +
      + (This portion of the API will be removed in a future release) + + +
      + setValue('tcp_legacy_api_enabled', e.currentTarget.checked)} + /> +
      + + + resetValue('tcp_legacy_api_enabled')} title="Reset to default"> + + + + ) } diff --git a/webui/src/UserConfig/TcpUdpProtocol.jsx b/webui/src/UserConfig/TcpUdpProtocol.jsx index d62871b7e6..50163427a1 100644 --- a/webui/src/UserConfig/TcpUdpProtocol.jsx +++ b/webui/src/UserConfig/TcpUdpProtocol.jsx @@ -4,84 +4,104 @@ import { UserConfigContext } from '../util' export function TcpUdpProtocol() { const config = useContext(UserConfigContext) + const tcpPort = + config?.tcp_enabled && config?.tcp_listen_port && config?.tcp_listen_port !== '0' + ? config?.tcp_listen_port + : 'disabled' + const udpPort = + config?.udp_enabled && config?.udp_listen_port && config?.udp_listen_port !== '0' + ? config?.udp_listen_port + : 'disabled' + return ( <>

      - Remote triggering can be done by sending TCP (port{' '} - - {config?.tcp_enabled && config?.tcp_listen_port && config?.tcp_listen_port !== '0' - ? config?.tcp_listen_port - : 'disabled'} - - ) or UDP (port{' '} - - {config?.udp_enabled && config?.udp_listen_port && config?.udp_listen_port !== '0' - ? config?.udp_listen_port - : 'disabled'} - - ) commands. + Remote triggering can be done by sending TCP (port {tcpPort}) or UDP (port {udpPort}) + commands.

      Commands:

      • - PAGE-SET <page number> <surface id> + SURFACE <surface id> PAGE-SET <page number>
        - Make device go to a specific page + Set a surface to a specific page
      • - PAGE-UP <surface id> + SURFACE <surface id> PAGE-UP
        - Page up on a specific device + Page up on a specific surface
      • - PAGE-DOWN <surface id> + SURFACE <surface id> PAGE-DOWN
        Page down on a specific surface
      • +
      • - BANK-PRESS <page> <button> + LOCATION <page>/<row>/<column>{' '} + BANK-PRESS
        Press and release a button (run both down and up actions)
      • - BANK-DOWN <page> <button> + LOCATION <page>/<row>/<column> BANK-DOWN
        Press the button (run down actions)
      • - BANK-UP <page> <button> + LOCATION <page>/<row>/<column> BANK-UP
        Release the button (run up actions)
      • - STYLE BANK <page> <button> TEXT - <text> + LOCATION <page>/<row>/<column>{' '} + ROTATE-LEFT +
        + Trigger a left rotation of the button/encode +
      • +
      • + LOCATION <page>/<row>/<column>{' '} + ROTATE-RIGHT +
        + Trigger a right rotation of the button/encode +
      • +
      • + LOCATION <page>/<row>/<column> SET-STEP{' '} + <step> +
        + Set the current step of a button/encoder +
      • + +
      • + LOCATION <page>/<row>/<column>{' '} + STYLE TEXT <text>
        Change text on a button
      • - STYLE BANK <page> <button> - COLOR <color HEX> + LOCATION <page>/<row>/<column>{' '} + STYLE COLOR <color HEX>
        Change text color on a button (#000000)
      • - STYLE BANK <page> <button> - BGCOLOR <color HEX> + LOCATION <page>/<row>/<column>{' '} + STYLE BGCOLOR <color HEX>
        Change background color on a button (#000000)
      • +
      • CUSTOM-VARIABLE <name> SET-VALUE <value>
        Change custom variable value
      • - RESCAN + SURFACES RESCAN
        - Make Companion rescan for newly attached USB surfaces + Make Companion rescan for USB surfaces
      @@ -92,13 +112,13 @@ export function TcpUdpProtocol() {

      Set the emulator surface to page 23
      - PAGE-SET 23 emulator + SURFACE emulator PAGE-SET 23

      - Press page 1 button 2 + Press page 1 row 2 column 3
      - BANK-PRESS 1 2 + LOCATION 1/2/3 PRESS

      @@ -106,6 +126,74 @@ export function TcpUdpProtocol() {
      CUSTOM-VARIABLE cue SET-VALUE intro

      + +

      + Deprecated Commands: +

      +

      + The following commands are deprecated and have replacements listed above. They will be removed in a future + version of Companion. +

      +
        +
      • + PAGE-SET <page number> <surface id> +
        + Make device go to a specific page +
      • +
      • + PAGE-UP <surface id> +
        + Page up on a specific device +
      • +
      • + PAGE-DOWN <surface id> +
        + Page down on a specific surface +
      • +
      • + BANK-PRESS <page> <button> +
        + Press and release a button (run both down and up actions) +
      • +
      • + BANK-DOWN <page> <button> +
        + Press the button (run down actions) +
      • +
      • + BANK-UP <page> <button> +
        + Release the button (run up actions) +
      • +
      • + STYLE BANK <page> <button> TEXT + <text> +
        + Change text on a button +
      • +
      • + STYLE BANK <page> <button> + COLOR <color HEX> +
        + Change text color on a button (#000000) +
      • +
      • + STYLE BANK <page> <button> + BGCOLOR <color HEX> +
        + Change background color on a button (#000000) +
      • +
      • + CUSTOM-VARIABLE <name> SET-VALUE <value> +
        + Change custom variable value +
      • +
      • + RESCAN +
        + Make Companion rescan for newly attached USB surfaces +
      • +
      ) } diff --git a/webui/src/UserConfig/UdpConfig.jsx b/webui/src/UserConfig/UdpConfig.jsx index f049e9d1ba..7c82612037 100644 --- a/webui/src/UserConfig/UdpConfig.jsx +++ b/webui/src/UserConfig/UdpConfig.jsx @@ -47,6 +47,28 @@ export function UdpConfig({ config, setValue, resetValue }) { + + + Deprecated UDP API +
      + (This portion of the API will be removed in a future release) + + +
      + setValue('udp_legacy_api_enabled', e.currentTarget.checked)} + /> +
      + + + resetValue('udp_legacy_api_enabled')} title="Reset to default"> + + + + ) } diff --git a/webui/src/UserConfig/index.jsx b/webui/src/UserConfig/index.jsx index 5ba3c09379..981c5bf268 100644 --- a/webui/src/UserConfig/index.jsx +++ b/webui/src/UserConfig/index.jsx @@ -21,6 +21,7 @@ import { RosstalkConfig } from './RosstalkConfig' import { ArtnetConfig } from './ArtnetConfig' import { GridConfig } from './GridConfig' import { VideohubServerConfig } from './VideohubServerConfig' +import { HttpConfig } from './HttpConfig' export const UserConfig = memo(function UserConfig() { return ( @@ -75,6 +76,7 @@ function UserConfigTable() { + diff --git a/yarn.lock b/yarn.lock index ff875aa3ab..b938494783 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1261,6 +1261,11 @@ resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d" integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q== +"@types/cookiejar@*": + version "2.1.3" + resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.3.tgz#c54976fb8f3a32ea8da844f59f0374dd39656e13" + integrity sha512-LZ8SD3LpNmLMDLkG2oCBjZg+ETnx6XdCjydUE0HwojDmnDfDUnhMKKbtth1TZh+hzcqb03azrYWoXLS8sMXdqg== + "@types/cors@^2.8.12", "@types/cors@^2.8.14": version "2.8.15" resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.15.tgz#eb143aa2f8807ddd78e83cbff141bbedd91b60ee" @@ -1550,6 +1555,21 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.2.tgz#01284dde9ef4e6d8cef6422798d9a3ad18a66f8b" integrity sha512-g7CK9nHdwjK2n0ymT2CW698FuWJRIx+RP6embAzZ2Qi8/ilIrA1Imt2LVSeHUzKvpoi7BhmmQcXz95eS0f2JXw== +"@types/superagent@*": + version "4.1.20" + resolved "https://registry.yarnpkg.com/@types/superagent/-/superagent-4.1.20.tgz#9248f55ac588794568f02fe9cac6d6ff2650b660" + integrity sha512-GfpwJgYSr3yO+nArFkmyqv3i0vZavyEG5xPd/o95RwpKYpsOKJYI5XLdxLpdRbZI3YiGKKdIOFIf/jlP7A0Jxg== + dependencies: + "@types/cookiejar" "*" + "@types/node" "*" + +"@types/supertest@^2.0.15": + version "2.0.15" + resolved "https://registry.yarnpkg.com/@types/supertest/-/supertest-2.0.15.tgz#3d032865048c84c6a3bbbf1f949145b917d2ff65" + integrity sha512-jUCZZ/TMcpGzoSaed9Gjr8HCf3HehExdibyw3OHHEL1als1KmyzcOZZH4MjbObI8TkWsEr7bc7gsW0WTDni+qQ== + dependencies: + "@types/superagent" "*" + "@types/stream-demux@*": version "8.0.1" resolved "https://registry.yarnpkg.com/@types/stream-demux/-/stream-demux-8.0.1.tgz#7e6003fa1590de6d344fced0efe7eadee18d5684" @@ -1964,6 +1984,11 @@ array-flatten@^2.1.2: resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-2.1.2.tgz#24ef80a28c1a893617e2149b0c6d0d788293b099" integrity sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ== +asap@^2.0.0: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== + asn1@evs-broadcast/node-asn1: version "0.5.4" resolved "https://codeload.github.com/evs-broadcast/node-asn1/tar.gz/0146823069e479e90595480dc90c72cafa161ba1" @@ -2493,6 +2518,11 @@ commander@^5.1.0: resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== +component-emitter@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" + integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== + compress-commons@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-5.0.1.tgz#e46723ebbab41b50309b27a0e0f6f3baed2d6590" @@ -2550,6 +2580,11 @@ cookie@~0.4.1: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== +cookiejar@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b" + integrity sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw== + core-util-is@~1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" @@ -2715,6 +2750,14 @@ detect-newline@^3.0.0: resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== +dezalgo@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.4.tgz#751235260469084c132157dfa857f386d4c33d81" + integrity sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig== + dependencies: + asap "^2.0.0" + wrappy "1" + diff-sequences@^29.6.3: version "29.6.3" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" @@ -3063,6 +3106,11 @@ fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0: resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== +fast-safe-stringify@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" + integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== + fastest-levenshtein@^1.0.12: version "1.0.16" resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz#210e61b6ff181de91ea9b3d1b84fdedd47e034e5" @@ -3175,6 +3223,16 @@ formdata-polyfill@^4.0.10: dependencies: fetch-blob "^3.1.2" +formidable@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/formidable/-/formidable-2.1.2.tgz#fa973a2bec150e4ce7cac15589d7a25fc30ebd89" + integrity sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g== + dependencies: + dezalgo "^1.0.4" + hexoid "^1.0.0" + once "^1.4.0" + qs "^6.11.0" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -3428,6 +3486,11 @@ hasown@^2.0.0: dependencies: function-bind "^1.1.2" +hexoid@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/hexoid/-/hexoid-1.0.0.tgz#ad10c6573fb907de23d9ec63a711267d9dc9bc18" + integrity sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g== + html-escaper@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" @@ -3869,6 +3932,13 @@ jest-message-util@^29.7.0: slash "^3.0.0" stack-utils "^2.0.3" +jest-mock-extended@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/jest-mock-extended/-/jest-mock-extended-3.0.5.tgz#ebf208e363f4f1db603b81fb005c4055b7c1c8b7" + integrity sha512-/eHdaNPUAXe7f65gHH5urc8SbRVWjYxBqmCgax2uqOBJy8UUcCBMN1upj1eZ8y/i+IqpyEm4Kq0VKss/GCCTdw== + dependencies: + ts-essentials "^7.0.3" + jest-mock@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.7.0.tgz#4e836cf60e99c6fcfabe9f99d017f3fdd50a6347" @@ -4460,7 +4530,7 @@ merge2@^1.3.0, merge2@^1.4.1: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -methods@~1.1.2: +methods@^1.1.2, methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== @@ -4490,6 +4560,11 @@ mime@1.6.0: resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== +mime@2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" + integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== + mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" @@ -5080,6 +5155,13 @@ qs@6.11.0: dependencies: side-channel "^1.0.4" +qs@^6.11.0: + version "6.11.2" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.2.tgz#64bea51f12c1f5da1bc01496f48ffcff7c69d7d9" + integrity sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA== + dependencies: + side-channel "^1.0.4" + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" @@ -5676,6 +5758,30 @@ strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== +superagent@^8.0.5: + version "8.1.2" + resolved "https://registry.yarnpkg.com/superagent/-/superagent-8.1.2.tgz#03cb7da3ec8b32472c9d20f6c2a57c7f3765f30b" + integrity sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA== + dependencies: + component-emitter "^1.3.0" + cookiejar "^2.1.4" + debug "^4.3.4" + fast-safe-stringify "^2.1.1" + form-data "^4.0.0" + formidable "^2.1.2" + methods "^1.1.2" + mime "2.6.0" + qs "^6.11.0" + semver "^7.3.8" + +supertest@^6.3.3: + version "6.3.3" + resolved "https://registry.yarnpkg.com/supertest/-/supertest-6.3.3.tgz#42f4da199fee656106fd422c094cf6c9578141db" + integrity sha512-EMCG6G8gDu5qEqRQ3JjjPs6+FYT1a7Hv5ApHvtSghmOFJYtsU5S+pSb6Y2EUeCEY3CmEL3mmQ8YWlPOzQomabA== + dependencies: + methods "^1.1.2" + superagent "^8.0.5" + supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" @@ -5818,6 +5924,11 @@ triple-beam@^1.3.0: resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.4.1.tgz#6fde70271dc6e5d73ca0c3b24e2d92afb7441984" integrity sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg== +ts-essentials@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-7.0.3.tgz#686fd155a02133eedcc5362dc8b5056cde3e5a38" + integrity sha512-8+gr5+lqO3G84KdiTSMRLtuyJ+nTBVRKuCrK4lidMPdVeEp0uqC875uE5NMcaA7YYMN7XsNiFQuMvasF8HT/xQ== + tslib@^1.13.0: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"