Skip to content

Commit ea7c4ac

Browse files
committed
feat: add deviceActionMessages to StudioBlueprintManifest and resolve action errors server-side
1 parent 30d3ce1 commit ea7c4ac

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
@@ -74,7 +74,7 @@ import { assertConnectionHasOneOfPermissions } from '../security/auth'
7474
import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio'
7575
import { getRootSubpath } from '../lib'
7676
import { evalBlueprint } from './blueprints/cache'
77-
import { StudioBlueprintManifest } from '@sofie-automation/blueprints-integration'
77+
import { StudioBlueprintManifest, TSR } from '@sofie-automation/blueprints-integration'
7878
import { StatusMessageResolver } from '@sofie-automation/corelib'
7979
import { interpollateTranslation } from '@sofie-automation/corelib/dist/TranslatableMessage'
8080
import { Blueprint } from '@sofie-automation/corelib/dist/dataModel/Blueprint'
@@ -188,6 +188,82 @@ async function resolveDeviceStatusDetails(
188188
}
189189
}
190190

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

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

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

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

0 commit comments

Comments
 (0)