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
4 changes: 3 additions & 1 deletion meteor/server/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
} from '../security/check'
import { UserActionsLog } from '../collections'
import { executePeripheralDeviceFunctionWithCustomTimeout } from './peripheralDevice/executeFunction'
import { resolveActionResult } from './peripheralDevice'
import { LeveledLogMethodFixed } from '@sofie-automation/corelib/dist/logging'
import { assertConnectionHasOneOfPermissions } from '../security/auth'

Expand Down Expand Up @@ -458,7 +459,7 @@ class ServerClientAPIClass extends MethodContextAPI implements NewClientAPI {
actionId: string,
payload?: Record<string, any>
) {
return ServerClientAPI.callPeripheralDeviceFunctionOrAction(
const result = await ServerClientAPI.callPeripheralDeviceFunctionOrAction(
this,
context,
deviceId,
Expand All @@ -470,6 +471,7 @@ class ServerClientAPIClass extends MethodContextAPI implements NewClientAPI {
actionId,
payload
)
return resolveActionResult(deviceId, result)
}
async callBackgroundPeripheralDeviceFunction(
deviceId: PeripheralDeviceId,
Expand Down
78 changes: 77 additions & 1 deletion meteor/server/api/peripheralDevice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ import { assertConnectionHasOneOfPermissions } from '../security/auth'
import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio'
import { getRootSubpath } from '../lib'
import { evalBlueprint } from './blueprints/cache'
import { StudioBlueprintManifest } from '@sofie-automation/blueprints-integration'
import { StudioBlueprintManifest, TSR } from '@sofie-automation/blueprints-integration'
import { StatusMessageResolver } from '@sofie-automation/corelib'
import { interpollateTranslation } from '@sofie-automation/corelib/dist/TranslatableMessage'
import { Blueprint } from '@sofie-automation/corelib/dist/dataModel/Blueprint'
Expand Down Expand Up @@ -189,6 +189,82 @@ async function resolveDeviceStatusDetails(
}
}

/**
* Resolve a TSR ActionExecutionResult using the Studio blueprint's deviceActionMessages.
* If the result has a structured `code` and `context`, and the blueprint defines a custom
* message template for that code, the `response` field is replaced with the resolved message.
*
* @param deviceId - The peripheral device ID (used to look up the studio and blueprint)
* @param result - The action execution result from TSR
* @returns The result with `response` resolved if a custom message was found
*/
export async function resolveActionResult(
deviceId: PeripheralDeviceId,
result: TSR.ActionExecutionResult
): Promise<TSR.ActionExecutionResult> {
if (result.result === TSR.ActionExecutionResultCode.Ok) return result
if (!result.code) return result

try {
const device = (await PeripheralDevices.findOneAsync(deviceId, {
projection: { name: 1, studioAndConfigId: 1 },
})) as Pick<PeripheralDevice, 'name' | 'studioAndConfigId'> | undefined

if (!device?.studioAndConfigId?.studioId) return result

const studio = (await Studios.findOneAsync(device.studioAndConfigId.studioId, {
projection: { blueprintId: 1 },
})) as Pick<DBStudio, 'blueprintId'> | undefined

if (!studio?.blueprintId) return result

const blueprint = (await Blueprints.findOneAsync(studio.blueprintId, {
projection: { _id: 1, name: 1, code: 1 },
})) as Pick<Blueprint, '_id' | 'name' | 'code'> | undefined

if (!blueprint) return result

const blueprintManifest = evalBlueprint(blueprint) as StudioBlueprintManifest

if (!blueprintManifest.deviceActionMessages) return result

const resolver = new StatusMessageResolver(blueprint._id, blueprintManifest.deviceActionMessages, undefined)

// Use the existing TSR response as the fallback default message
const defaultMessage = result.response?.key ?? ''

const resolved = resolver.getDeviceStatusMessage(
result.code,
{
...(result.context ?? {}),
deviceName: device.name,
deviceId: unprotectString(deviceId),
},
defaultMessage
)

if (resolved === null) {
// Message suppressed by blueprint
return result
}

// resolved.key is either the custom blueprint message or the defaultMessage
if (resolved.key === defaultMessage) {
// No custom message found - keep original response unchanged
return result
}
Comment on lines +246 to +255
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect StatusMessageResolver.getDeviceStatusMessage return semantics (null vs undefined on suppression)
fd -i statusmessageresolver -e ts | head
ast-grep --pattern 'getDeviceStatusMessage($$$) {
  $$$
}'
rg -nP -C4 '\bgetDeviceStatusMessage\b' --type=ts

Repository: Sofie-Automation/sofie-core

Length of output: 12279


Fix suppression handling for peripheral action status messages

  • resolveActionResult returns result unchanged when resolved === null, so the original TSR result.response is still surfaced even when the blueprint intends to “suppress” via an empty-string template (generic error behavior).
  • StatusMessageResolver.getDeviceStatusMessage is typed/documented to return ITranslatableMessage | null (null indicates suppression), so the === null check is the correct guard (no undefined/null mismatch).
  • Change the resolved === null branch to clear/replace result.response (and related translatable response fields) so the suppressed TSR message can’t reach the UI.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@meteor/server/api/peripheralDevice.ts` around lines 246 - 255, The branch
that returns early when resolved === null should instead remove/suppress the TSR
message so it doesn't reach the UI: in the function resolveActionResult (the
code path that calls StatusMessageResolver.getDeviceStatusMessage and assigns
resolved/defaultMessage), replace the early return with logic that clears
result.response and any translatable response fields (e.g.
result.responseTranslated and result.translatableResponse or similarly named
properties on result) and leaves other result metadata intact; keep the existing
defaultMessage check unchanged. Ensure you only mutate the response fields (set
to empty string or undefined per local patterns) when resolved === null so the
blueprint suppression takes effect.


const interpolated = interpollateTranslation(resolved.key, resolved.args)
return {
...result,
response: { key: interpolated },
}
} catch (e) {
logger.error(`Error resolving device action messages: ${e}`)
return result
}
}

export namespace ServerPeripheralDeviceAPI {
export async function initialize(
context: MethodContext,
Expand Down
25 changes: 25 additions & 0 deletions packages/blueprints-integration/src/api/studio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,31 @@ export interface StudioBlueprintManifest<
*/
deviceStatusMessages?: Record<string, string | DeviceStatusMessageFunction>

/**
* Alternate device action error messages, to override the default messages from TSR devices.
* Keys are action error code strings from TSR devices (e.g., 'ACTION_HTTPSEND_REQUEST_FAILED').
*
* Similar to deviceStatusMessages but applies to device action execution failures
* (e.g., HTTP Send failures, device restart failures) rather than ongoing status errors.
*
* Import action error codes from 'timeline-state-resolver-types' for type safety.
* Values can be:
* - String templates using {{variable}} syntax for interpolation with context values
* - Functions that receive DeviceStatusContext and return a custom message string
* - Empty string to suppress the message entirely (action result will show as generic error)
*
* @example
* ```typescript
* import { HttpSendActionErrorCode } from 'timeline-state-resolver-types'
*
* deviceActionMessages: {
* [HttpSendActionErrorCode.REQUEST_FAILED]: 'Failed to trigger graphics: {{errorMessage}}',
* [HttpSendActionErrorCode.MISSING_URL]: 'HTTP action not configured - missing URL',
* }
* ```
*/
deviceActionMessages?: Record<string, string | DeviceStatusMessageFunction | undefined>

/** Returns the items used to build the baseline (default state) of a studio, this is the baseline used when there's no active rundown */
getBaseline: (context: IStudioBaselineContext) => BlueprintResultStudioBaseline

Expand Down
Loading