Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions companion/lib/Controls/Controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,26 @@ export class ControlsController {
return variables
}

/**
* Get all expression variable definitions
* @returns Object with variable names as keys and their definitions
*/
getExpressionVariableDefinitions(): Record<string, { name: string; description: string }> {
const definitions: Record<string, { name: string; description: string }> = {}

for (const variable of this.getAllExpressionVariables()) {
const name = variable.options.variableName
if (name) {
definitions[name] = {
name,
description: variable.options.description,
}
}
}

return definitions
}

getExpressionVariableByName(name: string): ControlExpressionVariable | undefined {
if (!name) return undefined

Expand Down
2 changes: 1 addition & 1 deletion companion/lib/ImportExport/Export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export class ExportController {
}

#exportTriggerSingleHandler: RequestHandler = (req, res, next) => {
const control = this.#controlsController.getTrigger(req.params.id)
const control = this.#controlsController.getTrigger(String(req.params.id))
if (control) {
const exp = this.#generateTriggersExport([control], {
includeCollections: false,
Expand Down
162 changes: 158 additions & 4 deletions companion/lib/Service/HttpApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { ControlLocation } from '@companion-app/shared/Model/Common.js'
import LogController from '../Log/Controller.js'
import type { DataUserConfig } from '../Data/UserConfig.js'
import type { ServiceApi } from './ServiceApi.js'
import { Registry, Gauge } from 'prom-client'

const HTTP_API_SURFACE_ID = 'http'

Expand Down Expand Up @@ -320,6 +321,12 @@ export class ServiceHttpApi {
// surfaces
this.#apiRouter.post('/surfaces/rescan', this.#surfacesRescan)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding reporting of surface state (e.g. offline) would also be really nice, but maybe that's a separate issue/PR?


// JSON endpoints
this.#apiRouter.get('/variables/:label/json', this.#variablesGetJson)

// Prometheus metrics
this.#apiRouter.get('/variables/:label/prometheus', this.#variablesGetPrometheus)

// Finally, default all unhandled to 404
this.#apiRouter.use((_req, res) => {
res.status(404).send('')
Expand Down Expand Up @@ -558,7 +565,7 @@ export class ServiceHttpApi {
* Perform custom variable set value
*/
#customVariableSetValue = (req: Express.Request, res: Express.Response): void => {
const variableName = req.params.name
const variableName = String(req.params.name)
let variableValue = null
let variableError = true

Expand Down Expand Up @@ -606,7 +613,7 @@ export class ServiceHttpApi {
* Retrieve a custom variable current value
*/
#customVariableGetValue = (req: Express.Request, res: Express.Response): void => {
const variableName = req.params.name
const variableName = String(req.params.name)

this.logger.debug(`Got HTTP custom variable get value name "${variableName}"`)

Expand All @@ -625,8 +632,8 @@ export class ServiceHttpApi {
* Retrieve any module variable value
*/
#moduleVariableGetValue = (req: Express.Request, res: Express.Response): void => {
const connectionLabel = req.params.label
const variableName = req.params.name
const connectionLabel = String(req.params.label)
const variableName = String(req.params.name)

this.logger.debug(`Got HTTP module variable get value name "${connectionLabel}:${variableName}"`)

Expand All @@ -641,4 +648,151 @@ export class ServiceHttpApi {
}
}
}

/**
* Provides JSON for module or custom variables
*/
#variablesGetJson = (req: Express.Request, res: Express.Response): void => {
const connectionLabel = String(req.params.label)
this.logger.debug(`Got HTTP /api/variables/${connectionLabel}/json`)

// Determine variable type and fetch definitions
const isCustomVariable = connectionLabel === 'custom'
const isExpressionVariable = connectionLabel === 'expression'

let variableDefinitions: Record<string, any>

if (isCustomVariable) {
variableDefinitions = this.#serviceApi.getCustomVariableDefinitions()
} else if (isExpressionVariable) {
variableDefinitions = this.#serviceApi.getExpressionVariableDefinitions()
} else {
variableDefinitions = this.#serviceApi.getConnectionVariableDefinitions(connectionLabel)
}

// Check if connection/module exists by checking if we got any definitions
if (!variableDefinitions || Object.keys(variableDefinitions).length === 0) {
if (!isCustomVariable && !isExpressionVariable) {
res.status(404).json({
error: 'Connection not found',
connection: connectionLabel,
})
return
}
}

const result: Record<string, any> = {}

for (const [variableName, obj] of Object.entries(variableDefinitions)) {
let value: any
if (isCustomVariable) {
value = this.#serviceApi.getCustomVariableValue(variableName)
} else if (isExpressionVariable) {
value = this.#serviceApi.getConnectionVariableValue(connectionLabel, variableName)
} else {
value = this.#serviceApi.getConnectionVariableValue(connectionLabel, variableName)
}

result[variableName] = {
value,
name: connectionLabel,
...obj,
}
}

res.json({
connection: connectionLabel,
variables: result,
})
}

/**
* Provides Prometheus metrics for module or custom variables
*/
#variablesGetPrometheus = (req: Express.Request, res: Express.Response): void => {
const connectionLabel = String(req.params.label)
this.logger.debug(`Got HTTP /api/variables/${connectionLabel}/prometheus`)

// Determine variable type and fetch definitions
const isCustomVariable = connectionLabel === 'custom'
const isExpressionVariable = connectionLabel === 'expression'

let variableDefinitions: Record<string, any>

if (isCustomVariable) {
variableDefinitions = this.#serviceApi.getCustomVariableDefinitions()
} else if (isExpressionVariable) {
variableDefinitions = this.#serviceApi.getExpressionVariableDefinitions()
} else {
variableDefinitions = this.#serviceApi.getConnectionVariableDefinitions(connectionLabel)
}

// Check if connection exists
if (!variableDefinitions || Object.keys(variableDefinitions).length === 0) {
if (!isCustomVariable && !isExpressionVariable) {
res.status(404).send('# Connection not found\n')
return
}
}

// Create a new registry for this request
const register = new Registry()

// Create a single gauge metric for all variables
const gauge = new Gauge({
name: 'companion_variable',
help: 'Companion module variables',
labelNames: ['connection', 'variable_name', 'variable_type', 'value'],
registers: [register],
})

for (const [variableName, _obj] of Object.entries(variableDefinitions)) {
let value: any
if (isCustomVariable) {
value = this.#serviceApi.getCustomVariableValue(variableName)
} else if (isExpressionVariable) {
value = this.#serviceApi.getConnectionVariableValue(connectionLabel, variableName)
} else {
value = this.#serviceApi.getConnectionVariableValue(connectionLabel, variableName)
}

// Convert value to string for label consistency
const stringValue = value !== null && value !== undefined ? String(value) : ''

// Create labels object
const labels = {
connection: connectionLabel,
variable_name: variableName,
value: stringValue,
}

// Determine gauge value: use actual number/boolean value, or 1 for info pattern
let gaugeValue = 1
if (typeof value === 'number') {
gaugeValue = value
} else if (typeof value === 'boolean') {
gaugeValue = value ? 1 : 0
} else if (typeof value === 'string' && value.trim() !== '') {
const trimmed = value.trim()
if (/^-?\d+([.,]\d+)?([eE][+-]?\d+)?$/.test(trimmed)) {
const normalized = trimmed.replace(',', '.')
gaugeValue = Number(normalized)
}
}

gauge.set(labels, gaugeValue)
}

// Return metrics in Prometheus format
res.header('Content-Type', register.contentType)
register.metrics().then(
(metrics) => {
res.send(metrics)
},
(err) => {
this.logger.error(`Error generating Prometheus metrics: ${err}`)
res.status(500).send('# Error generating metrics\n')
}
)
}
}
8 changes: 8 additions & 0 deletions companion/lib/Service/ServiceApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,14 @@ export class ServiceApi extends EventEmitter<ServiceApiEvents> {
return this.#variablesController.custom.getDefinitions()
}

/**
* Get all expression variable definitions
* @returns Object with variable names as keys and their definitions
*/
getExpressionVariableDefinitions(): ModuleVariableDefinitions {
return this.#controlController.getExpressionVariableDefinitions()
}

async triggerRescanForSurfaces(): Promise<void> {
await this.#surfaceController.triggerRefreshDevices()
}
Expand Down
1 change: 1 addition & 0 deletions companion/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"p-debounce": "^5.1.0",
"p-queue": "^9.0.1",
"path-to-regexp": "^8.3.0",
"prom-client": "^15.1.3",
"quick-lru": "^7.3.0",
"selfsigned": "^5.4.0",
"semver": "^7.7.3",
Expand Down
38 changes: 35 additions & 3 deletions docs/user-guide/5_remote-control/http-remote-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Remote triggering can be done by sending `HTTP` Requests to the same IP and port

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.

### Buttons

- Press and release a button (run both down and up actions)
Method: POST
Path: `/api/location/<page>/<row>/<column>/press`
Expand Down Expand Up @@ -51,6 +53,14 @@ This API tries to follow REST principles, and the convention that a `POST` reque
Path: `/api/location/<page>/<row>/<column>/style`
Body: `{ "text": "<text>" }`

### Surfaces

- Rescan for USB surfaces
Method: POST
Path: `/api/surfaces/rescan`

### Variables

- Change custom variable value
Method: POST
Path: `/api/custom-variable/<name>/value?value=<value>`
Expand All @@ -61,12 +71,34 @@ This API tries to follow REST principles, and the convention that a `POST` reque
- Get custom variable value
Method: GET
Path: `/api/custom-variable/<name>/value`
- Get Expression variable value
Method: GET
Path: `/api/variable/expression/<name>/value`
- Get Module variable value
Method: GET
Path: `/api/variable/<Connection Label>/<name>/value`
- Rescan for USB surfaces
Method: POST
Path: `/api/surfaces/rescan`

- Get all custom variables in JSON format
Method: GET
Path: `/api/variables/custom/json`
- Get all expression variables in JSON format
Method: GET
Path: `/api/variables/expression/json`
- Get all module/connection variables in JSON format
Method: GET
Path: `/api/variables/<Connection Label>/json`
- Get all custom variables as Prometheus metrics
Method: GET
Path: `/api/variables/custom/prometheus`
Returns metrics in Prometheus format with `companion_custom_variable_` prefix
- Get all expression variables as Prometheus metrics
Method: GET
Path: `/api/variables/expression/prometheus`
Returns metrics in Prometheus format with `companion_expression_variable_` prefix
- Get all module/connection variables as Prometheus metrics
Method: GET
Path: `/api/variables/<Connection Label>/prometheus`
Returns metrics in Prometheus format with `companion_connection_variable_` prefix and connection labels

## Examples

Expand Down
Loading