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"