Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 1 addition & 7 deletions companion/lib/Instance/Connection/ConnectionsRestApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
collectionResponse,
createCollectionSchema,
createSuccessSchema,
errorResponses,
ErrorResponseSchema,
successResponse,
} from '../../Service/RestApi/schemas/common.js'
Expand Down Expand Up @@ -371,13 +372,6 @@ const connectionIdParam = z.object({
.meta({ example: 'KJA1isEECHRDBTFjx-7tf' }),
})

const errorResponses = {
400: { description: 'Bad request', content: { 'application/json': { schema: ErrorResponseSchema } } },
401: { description: 'Unauthorized', content: { 'application/json': { schema: ErrorResponseSchema } } },
403: { description: 'Forbidden', content: { 'application/json': { schema: ErrorResponseSchema } } },
404: { description: 'Not found', content: { 'application/json': { schema: ErrorResponseSchema } } },
}

const connectionListQuery = z.object({
include_config: z.enum(['true', 'false']).optional().describe('Include connection config in response').meta({
example: 'true',
Expand Down
1 change: 1 addition & 0 deletions companion/lib/Service/RestApi/RestApiRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export function createRestApiRouter(
// Mount resource routers — each versioned independently
router.use(createAuthMiddleware(logger, tokenStore))
router.use(registry.instance.createRestApiRouter(logger))
router.use(registry.surfaces.createRestApiRouter(logger))

// Do not allow unknown v2 routes to fall through into the legacy /api router.
router.use((_req, _res, next) => next(RestApiError.notFound()))
Expand Down
2 changes: 2 additions & 0 deletions companion/lib/Service/RestApi/openapi.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi'
import { registerInstanceRestApiPaths } from '../../Instance/RestApi.js'
import type { AppInfo } from '../../Registry.js'
import { registerSurfacePaths } from '../../Surface/SurfacesRestApi.js'
import { REST_API_BASE_PATH } from './constants.js'
import { createOpenApiRegistry } from './registry.js'

Expand All @@ -15,6 +16,7 @@ export function generateOpenApiDocument(

// Register all route paths into the registry
registerInstanceRestApiPaths(registry)
registerSurfacePaths(registry)

const generator = new OpenApiGeneratorV3(registry.definitions)

Expand Down
8 changes: 8 additions & 0 deletions companion/lib/Service/RestApi/schemas/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ export const ErrorResponseSchema = z
})
.meta({ example: { error: { code: 'unauthorized', message: 'Missing bearer token' } } })

/** Error responses returned by every endpoint, for OpenAPI docs */
export const errorResponses = {
400: { description: 'Bad request', content: { 'application/json': { schema: ErrorResponseSchema } } },
401: { description: 'Unauthorized', content: { 'application/json': { schema: ErrorResponseSchema } } },
403: { description: 'Forbidden', content: { 'application/json': { schema: ErrorResponseSchema } } },
404: { description: 'Not found', content: { 'application/json': { schema: ErrorResponseSchema } } },
}

/** Create a typed single-item success envelope schema for OpenAPI docs */
export function createSuccessSchema<T extends z.ZodType>(itemSchema: T): z.ZodObject<{ data: T }> {
const schema = z.object({ data: itemSchema })
Expand Down
8 changes: 7 additions & 1 deletion companion/lib/Surface/Controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import { createHash } from 'node:crypto'
import { EventEmitter } from 'node:events'
import debounceFn from 'debounce-fn'
import type express from 'express'
import jsonPatch from 'fast-json-patch'
import HID from 'node-hid'
import pDebounce from 'p-debounce'
Expand Down Expand Up @@ -45,7 +46,7 @@ import {
type SurfaceOpener,
} from '../Instance/Surface/DiscoveredSurfaceRegistry.js'
import type { CheckDeviceInfo } from '../Instance/Surface/IpcTypes.js'
import LogController from '../Log/Controller.js'
import LogController, { type Logger } from '../Log/Controller.js'
import { publicProcedure, router, toIterable } from '../UI/TRPC.js'
import { createOrSanitizeSurfaceHandlerConfig, PanelDefaults } from './Config.js'
import { SurfaceGroup, validateGroupConfigValue } from './Group.js'
Expand All @@ -54,6 +55,7 @@ import { EmulatorRoom, SurfaceIPElgatoEmulator } from './IP/ElgatoEmulator.js'
import { SurfaceIPSatellite, type SatelliteDeviceInfo } from './IP/Satellite.js'
import { SurfaceOutboundController } from './Outbound.js'
import type { SurfacePluginPanel } from './PluginPanel.js'
import { createSurfacesRestApiRouter } from './SurfacesRestApi.js'
import type { SurfaceHandlerDependencies, SurfacePanel, UpdateEvents } from './Types.js'

/**
Expand Down Expand Up @@ -413,6 +415,10 @@ export class SurfaceController extends EventEmitter<SurfaceControllerEvents> {
return handler
}

createRestApiRouter(logger: Logger): express.Router {
return createSurfacesRestApiRouter(logger, this, this.#handlerDependencies.pageStore)
}

createTrpcRouter() {
const self = this
const selfEvents = this as EventEmitter<SurfaceControllerEvents>
Expand Down
253 changes: 253 additions & 0 deletions companion/lib/Surface/SurfacesRestApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
import type { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'
import Express from 'express'
import z from 'zod'
import type { Logger } from '../Log/Controller.js'
import type { IPageStore } from '../Page/Store.js'
import { RestApiError } from '../Service/RestApi/errors.js'
import {
collectionResponse,
createCollectionSchema,
createSuccessSchema,
errorResponses,
ErrorResponseSchema,
successResponse,
} from '../Service/RestApi/schemas/common.js'
import {
createRestEndpointSpecFactory,
mountRestEndpoint,
registerRestEndpoint,
type RestEndpointSpec,
} from '../Service/RestApi/typedRoute.js'
import type { SurfaceController } from './Controller.js'

/** Schema for the page a surface is currently showing */
const SurfacePageSchema = z.object({
id: z.string().describe('Unique page id.').meta({ example: 'ggmHXCUQ0RRXUwEr8HHtQ' }),
number: z
.number()
.nullable()
.describe('Position of the page in the page list, or null if the page is no longer in the list.')
.meta({ example: 1 }),
name: z.string().describe('Display name of the page.').meta({ example: 'Main' }),
Comment thread
FusselTV marked this conversation as resolved.
Outdated
})

/** Schema for the grid size of a surface */
const SurfaceSizeSchema = z.object({
rows: z.number().describe('Number of button rows on the surface.').meta({ example: 4 }),
columns: z.number().describe('Number of button columns on the surface.').meta({ example: 8 }),
})

const SurfaceResponseExample = {
id: 'streamdeck:1A2B3C4D',
type: 'Elgato Stream Deck XL',
integrationType: 'elgato-stream-deck',
name: 'Front of house',
displayName: 'Front of house (streamdeck:1A2B3C4D)',
isConnected: true,
size: { rows: 4, columns: 8 },
brightness: 80,
page: { id: 'ggmHXCUQ0RRXUwEr8HHtQ', number: 1, name: 'Main' },
}

/** Schema for a surface in API responses — used for both validation and stripping */
const SurfaceResponseSchema = z
.object({
id: z.string().describe('Unique surface id.').meta({ example: SurfaceResponseExample.id }),
type: z
.string()
.describe('Model of the surface, as reported by the integration.')
.meta({ example: SurfaceResponseExample.type }),
integrationType: z
.string()
.describe('Integration the surface is connected through.')
.meta({ example: SurfaceResponseExample.integrationType }),
name: z.string().describe('Name given to the surface in Companion.').meta({ example: SurfaceResponseExample.name }),
displayName: z
.string()
.describe('Name shown for the surface in the Companion UI.')
.meta({ example: SurfaceResponseExample.displayName }),
isConnected: z.boolean().describe('Whether the surface is currently connected.').meta({ example: true }),
size: SurfaceSizeSchema.nullable().describe('Button grid size of the surface, if known.'),
brightness: z
.number()
.nullable()
.describe('Brightness of the surface in percent, or null if the surface has no brightness control.')
.meta({ example: 80 }),
Comment thread
FusselTV marked this conversation as resolved.
page: SurfacePageSchema.nullable().describe(
'Page the surface is currently showing, or null if it is not showing one.'
),
})
.meta({ example: SurfaceResponseExample })

/** Schema for partially updating a surface */
const SurfacePatchBodySchema = z
.object({
brightness: z
.number()
.int()
.min(0)
.max(100)
.describe('Brightness to apply to the surface, in percent.')
.meta({ example: 50 }),
})
.strict()

type SurfaceResponse = z.infer<typeof SurfaceResponseSchema>

const SURFACES_API_BASE_PATH = '/surfaces/v1'
const SURFACES_API_TAGS = ['Surfaces']

type SurfacesRestContext = {
logger: Logger
surfaceController: SurfaceController
pageStore: IPageStore
}

const defineSurfaceEndpointSpec = createRestEndpointSpecFactory<SurfacesRestContext>()

/**
* Create the surfaces router for /api/v2/surfaces/v1
*/
export function createSurfacesRestApiRouter(
logger: Logger,
surfaceController: SurfaceController,
pageStore: IPageStore
): Express.Router {
const surfacesRouter = Express.Router()
const surfacesLogger = logger.child({ source: 'surfaces/v1' })

for (const endpointSpec of surfaceEndpointSpecs) {
mountRestEndpoint(
surfacesRouter,
endpointSpec.createEndpoint({ logger: surfacesLogger, surfaceController, pageStore })
)
}

const router = Express.Router()
router.use(SURFACES_API_BASE_PATH, surfacesRouter)

return router
}

const surfaceIdParam = z.object({
surfaceId: z
.string()
.describe('Surface id, as returned by the list surfaces endpoint.')
.meta({ example: SurfaceResponseExample.id }),
})

const surfaceEndpointSpecs: RestEndpointSpec<SurfacesRestContext>[] = [
defineSurfaceEndpointSpec(
{
method: 'get',
path: '/',
scopes: ['read'],
tags: SURFACES_API_TAGS,
summary: 'List all surfaces',
description: 'Returns all known surfaces, connected or not, with their current state.',
response: {
status: 200,
description: 'List of surfaces',
schema: createCollectionSchema(SurfaceResponseSchema),
},
examples: {
response: collectionResponse([SurfaceResponseExample], { total: 1, limit: 1, offset: 0 }),
},
errorResponses,
},
({ surfaceController, pageStore }) => {
return () => {
const surfaces = listSurfaces(surfaceController, pageStore)

return {
body: collectionResponse(surfaces, { total: surfaces.length, limit: surfaces.length, offset: 0 }),
}
}
}
),

defineSurfaceEndpointSpec(
{
method: 'patch',
path: '/:surfaceId',
scopes: ['write'],
tags: SURFACES_API_TAGS,
summary: 'Update a surface',
description: 'Update a connected surface. Currently only the brightness can be changed.',
request: {
params: surfaceIdParam,
body: SurfacePatchBodySchema,
},
response: {
status: 200,
description: 'Updated surface',
schema: createSuccessSchema(SurfaceResponseSchema),
},
examples: {
body: { brightness: 50 },
response: successResponse({ ...SurfaceResponseExample, brightness: 50 }),
},
extraResponses: {
409: {
description: 'Surface is not connected',
content: { 'application/json': { schema: ErrorResponseSchema } },
},
},
errorResponses,
},
({ logger, surfaceController, pageStore }) => {
return ({ params, body }) => {
const { surfaceId } = params
const { brightness } = body

const surface = listSurfaces(surfaceController, pageStore).find((surface) => surface.id === surfaceId)
if (!surface) throw RestApiError.notFound('Surface not found')
if (!surface.isConnected) throw RestApiError.conflict('Surface is not connected')

surfaceController.setDeviceBrightness(surfaceId, brightness)

logger.info(`Set brightness of surface "${surface.displayName}" (${surfaceId}) to ${brightness}`)
return { body: successResponse({ ...surface, brightness }) }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
),
]

/**
* Build the validated SurfaceResponse for every known surface.
*/
function listSurfaces(surfaceController: SurfaceController, pageStore: IPageStore): SurfaceResponse[] {
return surfaceController.getDevicesList().flatMap((group) => {
// All surfaces in a group share the same page
const page = buildSurfacePage(surfaceController, pageStore, group.id)

return group.surfaces.map((surface) =>
SurfaceResponseSchema.parse({ ...surface, brightness: surface.brightness ?? null, page })
)
})
}

/**
* Resolve the page a surface group is currently showing
*/
function buildSurfacePage(
surfaceController: SurfaceController,
pageStore: IPageStore,
groupId: string
): SurfaceResponse['page'] {
const pageId = surfaceController.devicePageGet(groupId)
if (!pageId) return null

const number = pageStore.getPageNumber(pageId)
return {
id: pageId,
number,
name: (number !== null ? pageStore.getPageName(number) : undefined) ?? '',
}
}

export function registerSurfacePaths(registry: OpenAPIRegistry): void {
for (const endpointSpec of surfaceEndpointSpecs) {
registerRestEndpoint(registry, SURFACES_API_BASE_PATH, endpointSpec.contract)
}
}
8 changes: 6 additions & 2 deletions companion/test/Instance/Connection/ConnectionsRestApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { InstanceConfigStore } from '../../../lib/Instance/ConfigStore.js'
import { ConnectionCreateBodySchema } from '../../../lib/Instance/Connection/ConnectionsRestApi.js'
import type { InstanceController } from '../../../lib/Instance/Controller.js'
import { createInstanceRestApiRouter } from '../../../lib/Instance/RestApi.js'
import type { Logger } from '../../../lib/Log/Controller.js'
import type { Registry } from '../../../lib/Registry.js'
import { REST_API_BASE_PATH } from '../../../lib/Service/RestApi/constants.js'
import { createRestApiRouter } from '../../../lib/Service/RestApi/RestApiRouter.js'
Expand Down Expand Up @@ -50,9 +51,12 @@ type TestService = {
function createTestRegistry(instanceController: InstanceController, configStore: InstanceConfigStore): Registry {
return {
instance: {
createRestApiRouter: (logger) => createInstanceRestApiRouter(logger, instanceController, configStore),
createRestApiRouter: (logger: Logger) => createInstanceRestApiRouter(logger, instanceController, configStore),
},
} as Registry
surfaces: {
createRestApiRouter: () => express.Router(),
},
} as unknown as Registry
}

function createService(): TestService {
Expand Down
Loading
Loading