Skip to content

Commit d4a6368

Browse files
committed
feat: add deviceActionMessages to StudioBlueprintManifest and resolve action errors server-side
1 parent c580d76 commit d4a6368

3 files changed

Lines changed: 105 additions & 2 deletions

File tree

meteor/server/api/client.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
} from '../security/check'
3131
import { UserActionsLog } from '../collections'
3232
import { executePeripheralDeviceFunctionWithCustomTimeout } from './peripheralDevice/executeFunction'
33+
import { resolveActionResult } from './peripheralDevice'
3334
import { LeveledLogMethodFixed } from '@sofie-automation/corelib/dist/logging'
3435
import { assertConnectionHasOneOfPermissions } from '../security/auth'
3536

@@ -458,7 +459,7 @@ class ServerClientAPIClass extends MethodContextAPI implements NewClientAPI {
458459
actionId: string,
459460
payload?: Record<string, any>
460461
) {
461-
return ServerClientAPI.callPeripheralDeviceFunctionOrAction(
462+
const result = await ServerClientAPI.callPeripheralDeviceFunctionOrAction(
462463
this,
463464
context,
464465
deviceId,
@@ -470,6 +471,7 @@ class ServerClientAPIClass extends MethodContextAPI implements NewClientAPI {
470471
actionId,
471472
payload
472473
)
474+
return resolveActionResult(deviceId, result)
473475
}
474476
async callBackgroundPeripheralDeviceFunction(
475477
deviceId: PeripheralDeviceId,

meteor/server/api/peripheralDevice.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ import { assertConnectionHasOneOfPermissions } from '../security/auth'
7575
import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio'
7676
import { getRootSubpath } from '../lib'
7777
import { evalBlueprint } from './blueprints/cache'
78-
import { StudioBlueprintManifest } from '@sofie-automation/blueprints-integration'
78+
import { StudioBlueprintManifest, TSR } from '@sofie-automation/blueprints-integration'
7979
import { StatusMessageResolver } from '@sofie-automation/corelib'
8080
import { interpollateTranslation } from '@sofie-automation/corelib/dist/TranslatableMessage'
8181
import { Blueprint } from '@sofie-automation/corelib/dist/dataModel/Blueprint'
@@ -189,6 +189,82 @@ async function resolveDeviceStatusDetails(
189189
}
190190
}
191191

192+
/**
193+
* Resolve a TSR ActionExecutionResult using the Studio blueprint's deviceActionMessages.
194+
* If the result has a structured `code` and `context`, and the blueprint defines a custom
195+
* message template for that code, the `response` field is replaced with the resolved message.
196+
*
197+
* @param deviceId - The peripheral device ID (used to look up the studio and blueprint)
198+
* @param result - The action execution result from TSR
199+
* @returns The result with `response` resolved if a custom message was found
200+
*/
201+
export async function resolveActionResult(
202+
deviceId: PeripheralDeviceId,
203+
result: TSR.ActionExecutionResult
204+
): Promise<TSR.ActionExecutionResult> {
205+
if (result.result === TSR.ActionExecutionResultCode.Ok) return result
206+
if (!result.code) return result
207+
208+
try {
209+
const device = (await PeripheralDevices.findOneAsync(deviceId, {
210+
projection: { name: 1, studioAndConfigId: 1 },
211+
})) as Pick<PeripheralDevice, 'name' | 'studioAndConfigId'> | undefined
212+
213+
if (!device?.studioAndConfigId?.studioId) return result
214+
215+
const studio = (await Studios.findOneAsync(device.studioAndConfigId.studioId, {
216+
projection: { blueprintId: 1 },
217+
})) as Pick<DBStudio, 'blueprintId'> | undefined
218+
219+
if (!studio?.blueprintId) return result
220+
221+
const blueprint = (await Blueprints.findOneAsync(studio.blueprintId, {
222+
projection: { _id: 1, name: 1, code: 1 },
223+
})) as Pick<Blueprint, '_id' | 'name' | 'code'> | undefined
224+
225+
if (!blueprint) return result
226+
227+
const blueprintManifest = evalBlueprint(blueprint) as StudioBlueprintManifest
228+
229+
if (!blueprintManifest.deviceActionMessages) return result
230+
231+
const resolver = new StatusMessageResolver(blueprint._id, blueprintManifest.deviceActionMessages, undefined)
232+
233+
// Use the existing TSR response as the fallback default message
234+
const defaultMessage = result.response?.key ?? ''
235+
236+
const resolved = resolver.getDeviceStatusMessage(
237+
result.code,
238+
{
239+
...(result.context ?? {}),
240+
deviceName: device.name,
241+
deviceId: unprotectString(deviceId),
242+
},
243+
defaultMessage
244+
)
245+
246+
if (resolved === null) {
247+
// Message suppressed by blueprint
248+
return result
249+
}
250+
251+
// resolved.key is either the custom blueprint message or the defaultMessage
252+
if (resolved.key === defaultMessage) {
253+
// No custom message found - keep original response unchanged
254+
return result
255+
}
256+
257+
const interpolated = interpollateTranslation(resolved.key, resolved.args)
258+
return {
259+
...result,
260+
response: { key: interpolated },
261+
}
262+
} catch (e) {
263+
logger.error(`Error resolving device action messages: ${e}`)
264+
return result
265+
}
266+
}
267+
192268
export namespace ServerPeripheralDeviceAPI {
193269
export async function initialize(
194270
context: MethodContext,

packages/blueprints-integration/src/api/studio.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,31 @@ export interface StudioBlueprintManifest<
112112
*/
113113
deviceStatusMessages?: Record<string, string | DeviceStatusMessageFunction>
114114

115+
/**
116+
* Alternate device action error messages, to override the default messages from TSR devices.
117+
* Keys are action error code strings from TSR devices (e.g., 'ACTION_HTTPSEND_REQUEST_FAILED').
118+
*
119+
* Similar to deviceStatusMessages but applies to device action execution failures
120+
* (e.g., HTTP Send failures, device restart failures) rather than ongoing status errors.
121+
*
122+
* Import action error codes from 'timeline-state-resolver-types' for type safety.
123+
* Values can be:
124+
* - String templates using {{variable}} syntax for interpolation with context values
125+
* - Functions that receive DeviceStatusContext and return a custom message string
126+
* - Empty string to suppress the message entirely (action result will show as generic error)
127+
*
128+
* @example
129+
* ```typescript
130+
* import { HttpSendActionErrorCode } from 'timeline-state-resolver-types'
131+
*
132+
* deviceActionMessages: {
133+
* [HttpSendActionErrorCode.REQUEST_FAILED]: 'Failed to trigger graphics: {{errorMessage}}',
134+
* [HttpSendActionErrorCode.MISSING_URL]: 'HTTP action not configured - missing URL',
135+
* }
136+
* ```
137+
*/
138+
deviceActionMessages?: Record<string, string | DeviceStatusMessageFunction | undefined>
139+
115140
/** Returns the items used to build the baseline (default state) of a studio, this is the baseline used when there's no active rundown */
116141
getBaseline: (context: IStudioBaselineContext) => BlueprintResultStudioBaseline
117142

0 commit comments

Comments
 (0)