Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
10 changes: 5 additions & 5 deletions meteor/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2421,7 +2421,7 @@ __metadata:
dependencies:
"@mos-connection/model": "npm:^5.0.0-alpha.0"
kairos-lib: "npm:^1.0.0"
timeline-state-resolver-types: "npm:10.0.0-nightly-main-20260601-094843-59ce43391.0"
timeline-state-resolver-types: "npm:10.0.0-nightly-main-20260602-133931-c0882da4d.0"
tslib: "npm:^2.8.1"
type-fest: "npm:^4.41.0"
languageName: node
Expand Down Expand Up @@ -12415,13 +12415,13 @@ __metadata:
languageName: node
linkType: hard

"timeline-state-resolver-types@npm:10.0.0-nightly-main-20260601-094843-59ce43391.0":
version: 10.0.0-nightly-main-20260601-094843-59ce43391.0
resolution: "timeline-state-resolver-types@npm:10.0.0-nightly-main-20260601-094843-59ce43391.0"
"timeline-state-resolver-types@npm:10.0.0-nightly-main-20260602-133931-c0882da4d.0":
version: 10.0.0-nightly-main-20260602-133931-c0882da4d.0
resolution: "timeline-state-resolver-types@npm:10.0.0-nightly-main-20260602-133931-c0882da4d.0"
dependencies:
kairos-lib: "npm:1.0.0"
tslib: "npm:^2.8.1"
checksum: 10/87a33b766a94e809cdcd3517f695c4e171bbb3a451ad390adbfc2ca31be8ccff0a3531e81f0a548b683d9fea964252043837b89c6cd12c7d27d0f7e9205b65f2
checksum: 10/8021f03e15a7b6f581ea4c5b77e809238a3e8120a7b0a8eed40d3a1afcb03749aaed33177b96a0c42078dcd19da08703f125ee3a1f28739bfcb9782344321bc8
languageName: node
linkType: hard

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
2 changes: 1 addition & 1 deletion packages/shared-lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"dependencies": {
"@mos-connection/model": "^5.0.0-alpha.0",
"kairos-lib": "^1.0.0",
"timeline-state-resolver-types": "10.0.0-nightly-main-20260601-094843-59ce43391.0",
"timeline-state-resolver-types": "10.0.0-nightly-main-20260602-133931-c0882da4d.0",
"tslib": "^2.8.1",
"type-fest": "^4.41.0"
},
Expand Down
12 changes: 11 additions & 1 deletion packages/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7142,7 +7142,7 @@ __metadata:
dependencies:
"@mos-connection/model": "npm:^5.0.0-alpha.0"
kairos-lib: "npm:^1.0.0"
timeline-state-resolver-types: "npm:10.0.0-nightly-main-20260601-094843-59ce43391.0"
timeline-state-resolver-types: "npm:10.0.0-nightly-main-20260602-133931-c0882da4d.0"
tslib: "npm:^2.8.1"
type-fest: "npm:^4.41.0"
languageName: unknown
Expand Down Expand Up @@ -28341,6 +28341,16 @@ asn1@evs-broadcast/node-asn1:
languageName: node
linkType: hard

"timeline-state-resolver-types@npm:10.0.0-nightly-main-20260602-133931-c0882da4d.0":
version: 10.0.0-nightly-main-20260602-133931-c0882da4d.0
resolution: "timeline-state-resolver-types@npm:10.0.0-nightly-main-20260602-133931-c0882da4d.0"
dependencies:
kairos-lib: "npm:1.0.0"
tslib: "npm:^2.8.1"
checksum: 10/8021f03e15a7b6f581ea4c5b77e809238a3e8120a7b0a8eed40d3a1afcb03749aaed33177b96a0c42078dcd19da08703f125ee3a1f28739bfcb9782344321bc8
languageName: node
linkType: hard

"timeline-state-resolver@npm:10.0.0-nightly-main-20260601-094843-59ce43391.0":
version: 10.0.0-nightly-main-20260601-094843-59ce43391.0
resolution: "timeline-state-resolver@npm:10.0.0-nightly-main-20260601-094843-59ce43391.0"
Expand Down
Loading