From 469016d2cbde84a73ca63e79488a83ae6a1a9763 Mon Sep 17 00:00:00 2001 From: Fussel Date: Tue, 30 Jun 2026 00:21:02 +0200 Subject: [PATCH 1/2] feat: http api to set surface brightness and list surfaces --- companion/lib/Service/HttpApi.ts | 57 ++++++++++ companion/lib/Service/ServiceApi.ts | 20 ++++ companion/lib/Surface/Controller.ts | 9 +- companion/test/Service/HttpApi.test.ts | 100 ++++++++++++++++++ .../5_remote-control/http-remote-control.md | 35 ++++++ 5 files changed, 217 insertions(+), 4 deletions(-) diff --git a/companion/lib/Service/HttpApi.ts b/companion/lib/Service/HttpApi.ts index 0c13c6c849..3abffe79be 100644 --- a/companion/lib/Service/HttpApi.ts +++ b/companion/lib/Service/HttpApi.ts @@ -319,7 +319,9 @@ export class ServiceHttpApi { this.#apiRouter.route('/variable/:label/:name/value').get(this.#moduleVariableGetValue) // surfaces + this.#apiRouter.get('/surfaces', this.#surfacesList) this.#apiRouter.post('/surfaces/rescan', this.#surfacesRescan) + this.#apiRouter.post('/surfaces/:id/brightness', this.#surfaceSetBrightness) // connections this.#apiRouter.get('/connections', this.#connectionsList) @@ -349,6 +351,61 @@ export class ServiceHttpApi { ) } + /** + * List all surfaces + */ + #surfacesList = (_req: Express.Request, res: Express.Response): void => { + this.logger.debug('Got HTTP GET /api/surfaces') + + const surfaces = this.#serviceApi.getSurfacesList().flatMap((group) => { + // All surfaces in a group share the same page, so resolve it once per group + const page = this.#serviceApi.getSurfacePage(group.id) + return group.surfaces.map((surface) => ({ + id: surface.id, + type: surface.type, + integrationType: surface.integrationType, + name: surface.name, + displayName: surface.displayName, + isConnected: surface.isConnected, + size: surface.size, + brightness: surface.brightness, + page, + })) + }) + + res.json(surfaces) + } + + /** + * Set the brightness of a surface + */ + #surfaceSetBrightness = (req: Express.Request, res: Express.Response): void => { + const surfaceId = req.params.id + + const rawValue = req.query.brightness + const brightness = Number(rawValue) + + this.logger.debug(`Got HTTP surface set brightness "${surfaceId}" to ${JSON.stringify(rawValue)}`) + + if ( + rawValue === undefined || + rawValue === '' || + !Number.isFinite(brightness) || + brightness < 0 || + brightness > 100 + ) { + res.status(400).send('Invalid brightness') + return + } + + if (!this.#serviceApi.surfaceSetBrightness(surfaceId, brightness)) { + res.status(404).send('Not found') + return + } + + res.send('ok') + } + /** * Perform surfaces rescan */ diff --git a/companion/lib/Service/ServiceApi.ts b/companion/lib/Service/ServiceApi.ts index 8fc8ee6624..4f4c710a6d 100644 --- a/companion/lib/Service/ServiceApi.ts +++ b/companion/lib/Service/ServiceApi.ts @@ -5,6 +5,7 @@ import type { ClientConnectionConfig } from '@companion-app/shared/Model/Connect import type { CustomVariablesModel } from '@companion-app/shared/Model/CustomVariableModel.js' import type { InstanceStatusEntry } from '@companion-app/shared/Model/InstanceStatus.js' import type { ButtonStyleProperties } from '@companion-app/shared/Model/StyleModel.js' +import type { ClientDevicesListItem } from '@companion-app/shared/Model/Surfaces.js' import type { ModuleVariableDefinitions, VariableValue } from '@companion-app/shared/Model/Variables.js' import type { ControlCommonEvents } from '../Controls/ControlDependencies.js' import type { IControlStore } from '../Controls/IControlStore.js' @@ -226,6 +227,25 @@ export class ServiceApi extends EventEmitter { this.#surfaceController.devicePageDown(surfaceId) } + surfaceSetBrightness(surfaceId: string, brightness: number): boolean { + return this.#surfaceController.setDeviceBrightness(surfaceId, brightness, true) + } + + getSurfacesList(): ClientDevicesListItem[] { + return this.#surfaceController.getDevicesList() + } + + /** + * Get the current page (id, number and name) a surface is showing + */ + getSurfacePage(surfaceId: string): { id: string; number: number | null; name: string } | null { + const pageId = this.#surfaceController.devicePageGet(surfaceId, true) + if (!pageId) return null + const number = this.getPageNumberForId(pageId) + const name = number !== null ? (this.#pageStore.getPageInfo(number)?.name ?? '') : '' + return { id: pageId, number, name } + } + getCachedRenderOrGeneratePlaceholder(location: ControlLocation): ImageResult { return this.#graphicsController.getCachedRenderOrGeneratePlaceholder(location) } diff --git a/companion/lib/Surface/Controller.ts b/companion/lib/Surface/Controller.ts index db0071ac64..af2c4c84aa 100644 --- a/companion/lib/Surface/Controller.ts +++ b/companion/lib/Surface/Controller.ts @@ -1927,12 +1927,13 @@ export class SurfaceController extends EventEmitter { * @param surfaceId * @param brightness 0-100 * @param looseIdMatching + * @returns whether a matching surface was found */ - setDeviceBrightness(surfaceId: string, brightness: number, looseIdMatching = false): void { + setDeviceBrightness(surfaceId: string, brightness: number, looseIdMatching = false): boolean { const device = this.#getSurfaceHandlerForId(surfaceId, looseIdMatching) - if (device) { - device.setBrightness(brightness) - } + if (!device) return false + device.setBrightness(brightness) + return true } /** diff --git a/companion/test/Service/HttpApi.test.ts b/companion/test/Service/HttpApi.test.ts index b3bf270feb..9ce7ec7b8e 100644 --- a/companion/test/Service/HttpApi.test.ts +++ b/companion/test/Service/HttpApi.test.ts @@ -130,6 +130,106 @@ describe('HttpApi', () => { expect(res.text).toBe('fail') }) }) + + describe('list', () => { + test('ok', async () => { + const { app, serviceApi } = createService() + serviceApi.getSurfacesList.mockReturnValue([ + { + id: 'group', + surfaces: [ + { + id: 'emulator', + type: 'Emulator', + integrationType: 'emulator', + name: '', + displayName: 'Emulator (emulator)', + isConnected: true, + size: { rows: 4, columns: 8 }, + brightness: 50, + }, + ], + }, + ] as any) + serviceApi.getSurfacePage.mockReturnValue({ id: 'abcd1234', number: 1, name: 'Main' }) + + const res = await supertest(app).get('/api/surfaces').send() + expect(res.status).toBe(200) + expect(res.body).toEqual([ + { + id: 'emulator', + type: 'Emulator', + integrationType: 'emulator', + name: '', + displayName: 'Emulator (emulator)', + isConnected: true, + size: { rows: 4, columns: 8 }, + brightness: 50, + page: { id: 'abcd1234', number: 1, name: 'Main' }, + }, + ]) + + expect(serviceApi.getSurfacePage).toHaveBeenCalledWith('group') + + expect(serviceApi.getSurfacesList).toHaveBeenCalledTimes(1) + }) + }) + + describe('set brightness', () => { + test('ok from query', async () => { + const { app, serviceApi } = createService() + serviceApi.surfaceSetBrightness.mockReturnValue(true) + + const res = await supertest(app).post('/api/surfaces/emulator/brightness?brightness=30').send() + expect(res.status).toBe(200) + expect(res.text).toBe('ok') + + expect(serviceApi.surfaceSetBrightness).toHaveBeenCalledTimes(1) + expect(serviceApi.surfaceSetBrightness).toHaveBeenCalledWith('emulator', 30) + }) + + test('unknown surface', async () => { + const { app, serviceApi } = createService() + serviceApi.surfaceSetBrightness.mockReturnValue(false) + + const res = await supertest(app).post('/api/surfaces/does-not-exist/brightness?brightness=30').send() + expect(res.status).toBe(404) + expect(res.text).toBe('Not found') + + expect(serviceApi.surfaceSetBrightness).toHaveBeenCalledTimes(1) + expect(serviceApi.surfaceSetBrightness).toHaveBeenCalledWith('does-not-exist', 30) + }) + + test('no value', async () => { + const { app, serviceApi } = createService() + + const res = await supertest(app).post('/api/surfaces/emulator/brightness').send() + expect(res.status).toBe(400) + expect(res.text).toBe('Invalid brightness') + + expect(serviceApi.surfaceSetBrightness).toHaveBeenCalledTimes(0) + }) + + test('out of range', async () => { + const { app, serviceApi } = createService() + + const res = await supertest(app).post('/api/surfaces/emulator/brightness?brightness=150').send() + expect(res.status).toBe(400) + expect(res.text).toBe('Invalid brightness') + + expect(serviceApi.surfaceSetBrightness).toHaveBeenCalledTimes(0) + }) + + test('not a number', async () => { + const { app, serviceApi } = createService() + + const res = await supertest(app).post('/api/surfaces/emulator/brightness?brightness=abc').send() + expect(res.status).toBe(400) + expect(res.text).toBe('Invalid brightness') + + expect(serviceApi.surfaceSetBrightness).toHaveBeenCalledTimes(0) + }) + }) }) describe('custom-variable', () => { diff --git a/docs/user-guide/5_remote-control/http-remote-control.md b/docs/user-guide/5_remote-control/http-remote-control.md index d3475acfb4..b43f5a68ed 100644 --- a/docs/user-guide/5_remote-control/http-remote-control.md +++ b/docs/user-guide/5_remote-control/http-remote-control.md @@ -68,6 +68,35 @@ This API tries to follow REST principles, and the convention that a `POST` reque Method: POST Path: `/api/surfaces/rescan` +### Surfaces + +- List all surfaces + Method: GET + Path: `/api/surfaces` + Response: JSON array of surface objects, each with `id`, `type`, `integrationType`, `name`, `displayName`, `isConnected`, `size` (`{rows, columns}` or `null`), `brightness` (0-100, or `null` if unknown), and `page` — an object `{id, number, name}` describing the current page (or `null` if unknown). + + ```json + [ + { + "id": "emulator:emulator", + "type": "Emulator", + "integrationType": "emulator", + "name": "", + "displayName": "Emulator (emulator:emulator)", + "isConnected": true, + "size": { "rows": 4, "columns": 8 }, + "brightness": 100, + "page": { "id": "abcd1234", "number": 1, "name": "Main" } + } + ] + ``` + +- Set the brightness of a surface + Method: POST + Path: `/api/surfaces//brightness?brightness=<0-100>` + Error (400): `Invalid brightness` if the value is missing or outside 0-100 + Error (404): `Not found` if no connected surface matches the given id + ### Connection Management - List all connections with their current status @@ -154,6 +183,12 @@ Content-Type `application/json` Body: `{"name":"Douglas", "answer":42}` - Body needs to be a valid JSON. The object will be stored in the variable value and will not be converted to a string. You can also use the data types boolean, number, array or null. JSON does not support sending undefined as a value, but we interpret an empty body as undefined, properties of an object can of course be undefined. +List all surfaces: +GET `/api/surfaces` + +Set the brightness of the emulator surface to 30%: +POST `/api/surfaces/emulator/brightness?brightness=30` + List all connections: GET `/api/connections` From f3d7b13175a20ec8abaf71a450cbab2f8569ab87 Mon Sep 17 00:00:00 2001 From: Fussel Date: Tue, 30 Jun 2026 02:22:28 +0200 Subject: [PATCH 2/2] fix: reject blank surface brightness values and align docs --- companion/lib/Service/HttpApi.ts | 7 ++++--- companion/test/Service/HttpApi.test.ts | 20 +++++++++++++++++++ .../5_remote-control/http-remote-control.md | 4 ++-- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/companion/lib/Service/HttpApi.ts b/companion/lib/Service/HttpApi.ts index 3abffe79be..9acec7a674 100644 --- a/companion/lib/Service/HttpApi.ts +++ b/companion/lib/Service/HttpApi.ts @@ -383,13 +383,14 @@ export class ServiceHttpApi { const surfaceId = req.params.id const rawValue = req.query.brightness - const brightness = Number(rawValue) + const brightnessText = typeof rawValue === 'string' ? rawValue.trim() : undefined + const brightness = Number(brightnessText) this.logger.debug(`Got HTTP surface set brightness "${surfaceId}" to ${JSON.stringify(rawValue)}`) if ( - rawValue === undefined || - rawValue === '' || + brightnessText === undefined || + brightnessText === '' || !Number.isFinite(brightness) || brightness < 0 || brightness > 100 diff --git a/companion/test/Service/HttpApi.test.ts b/companion/test/Service/HttpApi.test.ts index 9ce7ec7b8e..5dc4f0f6dc 100644 --- a/companion/test/Service/HttpApi.test.ts +++ b/companion/test/Service/HttpApi.test.ts @@ -229,6 +229,26 @@ describe('HttpApi', () => { expect(serviceApi.surfaceSetBrightness).toHaveBeenCalledTimes(0) }) + + test('empty value', async () => { + const { app, serviceApi } = createService() + + const res = await supertest(app).post('/api/surfaces/emulator/brightness?brightness=').send() + expect(res.status).toBe(400) + expect(res.text).toBe('Invalid brightness') + + expect(serviceApi.surfaceSetBrightness).toHaveBeenCalledTimes(0) + }) + + test('whitespace value', async () => { + const { app, serviceApi } = createService() + + const res = await supertest(app).post('/api/surfaces/emulator/brightness?brightness=%20').send() + expect(res.status).toBe(400) + expect(res.text).toBe('Invalid brightness') + + expect(serviceApi.surfaceSetBrightness).toHaveBeenCalledTimes(0) + }) }) }) diff --git a/docs/user-guide/5_remote-control/http-remote-control.md b/docs/user-guide/5_remote-control/http-remote-control.md index b43f5a68ed..f282b40113 100644 --- a/docs/user-guide/5_remote-control/http-remote-control.md +++ b/docs/user-guide/5_remote-control/http-remote-control.md @@ -94,7 +94,7 @@ This API tries to follow REST principles, and the convention that a `POST` reque - Set the brightness of a surface Method: POST Path: `/api/surfaces//brightness?brightness=<0-100>` - Error (400): `Invalid brightness` if the value is missing or outside 0-100 + Error (400): `Invalid brightness` if the value is missing, not a number, or outside 0-100 Error (404): `Not found` if no connected surface matches the given id ### Connection Management @@ -187,7 +187,7 @@ List all surfaces: GET `/api/surfaces` Set the brightness of the emulator surface to 30%: -POST `/api/surfaces/emulator/brightness?brightness=30` +POST `/api/surfaces/emulator:emulator/brightness?brightness=30` List all connections: GET `/api/connections`