Skip to content

Commit 63f4ff9

Browse files
authored
Merge pull request #1604 from bbc/rjmunro/error-message-customisation
feat: Status message customisation
2 parents b5232d9 + a69cc33 commit 63f4ff9

34 files changed

Lines changed: 1059 additions & 90 deletions

File tree

meteor/__mocks__/helpers/database.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ export async function setupMockPeripheralDevice(
127127
created: 1234,
128128
status: {
129129
statusCode: StatusCode.GOOD,
130+
statusDetails: [],
130131
},
131132
lastSeen: 1234,
132133
lastConnected: 1234,

meteor/server/__tests__/cronjobs.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,7 @@ describe('cronjobs', () => {
510510
name: props.deviceName,
511511
status: {
512512
statusCode: StatusCode.GOOD,
513+
statusDetails: [],
513514
},
514515
token: '',
515516
...props,

meteor/server/api/__tests__/peripheralDevice.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ describe('test peripheralDevice general API methods', () => {
203203
})
204204
await MeteorCall.peripheralDevice.setStatus(device._id, device.token, {
205205
statusCode: StatusCode.WARNING_MINOR,
206-
messages: ["Something's not right"],
206+
statusDetails: [{ message: "Something's not right" }],
207207
})
208208
expect(((await PeripheralDevices.findOneAsync(device._id)) as PeripheralDevice).status).toMatchObject({
209209
statusCode: StatusCode.WARNING_MINOR,
@@ -650,6 +650,7 @@ describe('test peripheralDevice general API methods', () => {
650650
lastSeen: 0,
651651
status: {
652652
statusCode: StatusCode.GOOD,
653+
statusDetails: [],
653654
},
654655
subType: '_process',
655656
token: 'MockToken',

meteor/server/api/peripheralDevice.ts

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,14 @@ import { Meteor } from 'meteor/meteor'
22
import { check, Match } from '../lib/check'
33
import _ from 'underscore'
44
import { PeripheralDeviceType, PeripheralDevice } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice'
5-
import { PeripheralDeviceCommands, PeripheralDevices, Rundowns, Studios, UserActionsLog } from '../collections'
5+
import {
6+
PeripheralDeviceCommands,
7+
PeripheralDevices,
8+
Rundowns,
9+
Studios,
10+
UserActionsLog,
11+
Blueprints,
12+
} from '../collections'
613
import { stringifyObjects, literal } from '@sofie-automation/corelib/dist/lib'
714
import { protectString, unprotectString } from '@sofie-automation/corelib/dist/protectedString'
815
import { getCurrentTime } from '../lib/lib'
@@ -37,6 +44,7 @@ import {
3744
PeripheralDeviceInitOptions,
3845
PeripheralDeviceStatusObject,
3946
TimelineTriggerTimeResult,
47+
DeviceStatusDetail,
4048
} from '@sofie-automation/shared-lib/dist/peripheralDevice/peripheralDeviceAPI'
4149
import { checkStudioExists } from '../optimizations'
4250
import {
@@ -65,8 +73,121 @@ import bodyParser from 'koa-bodyparser'
6573
import { assertConnectionHasOneOfPermissions } from '../security/auth'
6674
import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio'
6775
import { getRootSubpath } from '../lib'
76+
import { evalBlueprint } from './blueprints/cache'
77+
import { StudioBlueprintManifest } from '@sofie-automation/blueprints-integration'
78+
import { StatusMessageResolver } from '@sofie-automation/corelib'
79+
import { interpollateTranslation } from '@sofie-automation/corelib/dist/TranslatableMessage'
80+
import { Blueprint } from '@sofie-automation/corelib/dist/dataModel/Blueprint'
81+
import { StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids'
6882

6983
const apmNamespace = 'peripheralDevice'
84+
85+
/**
86+
* Resolve device status details using the Studio blueprint's deviceStatusMessages.
87+
* This allows blueprints to customize status messages shown to operators.
88+
*
89+
* @param studioId - The studio ID to look up the blueprint
90+
* @param deviceName - The peripheral device name (shorter than TSR's internal name)
91+
* @param deviceId - The peripheral device ID
92+
* @param statusDetails - Structured status details from TSR
93+
* @param defaultMessages - The original messages from TSR (used as fallback)
94+
*/
95+
async function resolveDeviceStatusDetails(
96+
studioId: StudioId,
97+
deviceName: string,
98+
deviceId: PeripheralDeviceId,
99+
statusDetails: DeviceStatusDetail[],
100+
defaultMessages: string[]
101+
): Promise<string[]> {
102+
try {
103+
// Get the studio and its blueprint
104+
const studio = (await Studios.findOneAsync(studioId, {
105+
projection: { blueprintId: 1 },
106+
})) as Pick<DBStudio, 'blueprintId'> | undefined
107+
108+
if (!studio?.blueprintId) {
109+
// No blueprint, return empty (caller will use original messages)
110+
return []
111+
}
112+
113+
// Get the blueprint code
114+
const blueprint = (await Blueprints.findOneAsync(studio.blueprintId, {
115+
projection: { _id: 1, name: 1, code: 1 },
116+
})) as Pick<Blueprint, '_id' | 'name' | 'code'> | undefined
117+
118+
if (!blueprint) {
119+
return []
120+
}
121+
122+
// Evaluate the blueprint to get the manifest with deviceStatusMessages
123+
const blueprintManifest = evalBlueprint(blueprint) as StudioBlueprintManifest
124+
125+
logger.debug(
126+
`Blueprint ${blueprint._id} deviceStatusMessages keys: ${Object.keys(blueprintManifest.deviceStatusMessages ?? {}).join(', ')}`
127+
)
128+
129+
if (!blueprintManifest.deviceStatusMessages) {
130+
// Blueprint doesn't define any custom status messages
131+
logger.debug(`Blueprint ${blueprint._id} has no deviceStatusMessages`)
132+
return []
133+
}
134+
135+
// Create resolver with the blueprint's status messages
136+
const resolver = new StatusMessageResolver(
137+
blueprint._id,
138+
blueprintManifest.deviceStatusMessages,
139+
undefined // No system error messages
140+
)
141+
142+
// Resolve each status detail
143+
const resolvedMessages: string[] = []
144+
for (let i = 0; i < statusDetails.length; i++) {
145+
const statusDetail = statusDetails[i]
146+
// statusDetail.message is always pre-rendered by TSR; use it as fallback if no defaultMessages entry
147+
const defaultMessage = defaultMessages[i] ?? statusDetail.message
148+
149+
if (!statusDetail.code) {
150+
// No structured code - use the pre-rendered TSR message directly
151+
resolvedMessages.push(defaultMessage)
152+
continue
153+
}
154+
155+
logger.debug(
156+
`Resolving status code: ${statusDetail.code}, context: ${JSON.stringify(statusDetail.context)}`
157+
)
158+
const message = resolver.getDeviceStatusMessage(
159+
statusDetail.code,
160+
{
161+
...statusDetail.context,
162+
// Override with peripheral device info (TSR might have longer names)
163+
deviceName,
164+
deviceId: unprotectString(deviceId),
165+
},
166+
defaultMessage
167+
)
168+
169+
if (message) {
170+
// Interpolate the message template with context values
171+
const interpolated = interpollateTranslation(message.key, message.args)
172+
logger.debug(`Resolved message for ${statusDetail.code}: ${interpolated}`)
173+
resolvedMessages.push(interpolated)
174+
// Also mutate statusDetail.message so the UI can read from statusDetails[].message directly
175+
statusDetail.message = interpolated
176+
} else {
177+
// Message suppressed by blueprint - clear the message so the UI doesn't show the raw TSR message
178+
statusDetail.message = ''
179+
logger.debug(`Message suppressed for ${statusDetail.code}`)
180+
}
181+
}
182+
183+
return resolvedMessages
184+
} catch (e) {
185+
// Log error but don't fail - fall back to original messages
186+
logger.error(`Error resolving device status messages: ${e}`)
187+
return []
188+
}
189+
}
190+
70191
export namespace ServerPeripheralDeviceAPI {
71192
export async function initialize(
72193
context: MethodContext,
@@ -141,6 +262,7 @@ export namespace ServerPeripheralDeviceAPI {
141262
created: getCurrentTime(),
142263
status: {
143264
statusCode: StatusCode.UNKNOWN,
265+
statusDetails: [],
144266
},
145267
connected: true,
146268
connectionId: options.connectionId,
@@ -203,6 +325,37 @@ export namespace ServerPeripheralDeviceAPI {
203325
throw new Meteor.Error(400, 'device status code is not known')
204326
}
205327

328+
// Resolve status messages using Studio blueprint if structured status details are present
329+
// Child devices (like casparcg0) don't have studioAndConfigId directly - get it from parent
330+
let studioId = peripheralDevice.studioAndConfigId?.studioId
331+
if (!studioId && peripheralDevice.parentDeviceId) {
332+
const parentDevice = await PeripheralDevices.findOneAsync(peripheralDevice.parentDeviceId, {
333+
projection: { studioAndConfigId: 1 },
334+
})
335+
studioId = parentDevice?.studioAndConfigId?.studioId
336+
}
337+
338+
logger.info(
339+
`Device ${deviceId} setStatus: statusDetails=${status.statusDetails?.length ?? 'undefined'}, messages=${status.messages?.length ?? 'undefined'}, studioId=${studioId ?? 'none'}`
340+
)
341+
if (status.statusDetails && status.statusDetails.length > 0) {
342+
if (studioId) {
343+
const resolvedMessages = await resolveDeviceStatusDetails(
344+
studioId,
345+
peripheralDevice.name,
346+
peripheralDevice._id,
347+
status.statusDetails,
348+
status.messages ?? []
349+
)
350+
// Use blueprint-resolved messages if available, otherwise fall back to statusDetails messages
351+
status.messages =
352+
resolvedMessages.length > 0 ? resolvedMessages : status.statusDetails.map((d) => d.message)
353+
} else {
354+
// No studio context, derive messages directly from statusDetails
355+
status.messages = status.statusDetails.map((d) => d.message)
356+
}
357+
}
358+
206359
// check if we have to update something:
207360
if (!_.isEqual(status, peripheralDevice.status)) {
208361
logger.info(

meteor/server/systemStatus/__tests__/systemStatus.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ describe('systemStatus', () => {
9090
$set: {
9191
status: literal<PeripheralDeviceStatusObject>({
9292
statusCode: StatusCode.WARNING_MAJOR,
93+
statusDetails: [],
9394
messages: [],
9495
}),
9596
},
@@ -116,6 +117,7 @@ describe('systemStatus', () => {
116117
$set: {
117118
status: literal<PeripheralDeviceStatusObject>({
118119
statusCode: StatusCode.GOOD,
120+
statusDetails: [],
119121
messages: [],
120122
}),
121123
},

meteor/yarn.lock

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2421,7 +2421,7 @@ __metadata:
24212421
dependencies:
24222422
"@mos-connection/model": "npm:^5.0.0-alpha.0"
24232423
kairos-lib: "npm:^1.0.0"
2424-
timeline-state-resolver-types: "npm:10.0.0-nightly-main-20260429-110933-d01800ef9.0"
2424+
timeline-state-resolver-types: "npm:10.0.0-nightly-main-20260514-152958-5f6033589.0"
24252425
tslib: "npm:^2.8.1"
24262426
type-fest: "npm:^4.41.0"
24272427
languageName: node
@@ -12415,13 +12415,13 @@ __metadata:
1241512415
languageName: node
1241612416
linkType: hard
1241712417

12418-
"timeline-state-resolver-types@npm:10.0.0-nightly-main-20260429-110933-d01800ef9.0":
12419-
version: 10.0.0-nightly-main-20260429-110933-d01800ef9.0
12420-
resolution: "timeline-state-resolver-types@npm:10.0.0-nightly-main-20260429-110933-d01800ef9.0"
12418+
"timeline-state-resolver-types@npm:10.0.0-nightly-main-20260514-152958-5f6033589.0":
12419+
version: 10.0.0-nightly-main-20260514-152958-5f6033589.0
12420+
resolution: "timeline-state-resolver-types@npm:10.0.0-nightly-main-20260514-152958-5f6033589.0"
1242112421
dependencies:
1242212422
kairos-lib: "npm:1.0.0"
1242312423
tslib: "npm:^2.8.1"
12424-
checksum: 10/cbd39136ce786cdcde9faa72242e256bb3dbbf27b28088399924544f2f7a655cfc39f77b035ee0b46b4dfed86ee379869104d7ee64b75b04519adb51d367a853
12424+
checksum: 10/debb3faa9eda1f4ab2a85165c052cfc5b8698367d5a84be8b4095dbdaf6a40175e4c5ec80f09199e7650b53280e8c730f85bbd30685e08d3ea382ca08ee84e8b
1242512425
languageName: node
1242612426
linkType: hard
1242712427

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,26 @@ import type { MosGatewayConfig } from '@sofie-automation/shared-lib/dist/generat
4141
import type { PlayoutGatewayConfig } from '@sofie-automation/shared-lib/dist/generated/PlayoutGatewayConfigTypes'
4242
import type { LiveStatusGatewayConfig } from '@sofie-automation/shared-lib/dist/generated/LiveStatusGatewayOptionsTypes'
4343

44+
/**
45+
* Context provided to device status message functions.
46+
* Contains the device name, device ID, and any additional context from the TSR status detail.
47+
*/
48+
export interface DeviceStatusContext {
49+
/** Human-readable name of the device */
50+
deviceName: string
51+
/** Internal device ID */
52+
deviceId?: string
53+
/** Additional context values from the TSR error (e.g., host, port, channel, etc.) */
54+
[key: string]: unknown
55+
}
56+
57+
/**
58+
* A function that receives device status context and returns a custom status message.
59+
* Return `undefined` to fall back to the default TSR message.
60+
* Return an empty string `''` to suppress the message entirely.
61+
*/
62+
export type DeviceStatusMessageFunction = (context: DeviceStatusContext) => string | undefined
63+
4464
export interface StudioBlueprintManifest<
4565
TRawConfig = IBlueprintConfig,
4666
TProcessedConfig = unknown,
@@ -56,6 +76,39 @@ export interface StudioBlueprintManifest<
5676
/** Translations connected to the studio (as stringified JSON) */
5777
translations?: string
5878

79+
/**
80+
* Alternate device status messages, to override the default messages from TSR devices.
81+
* Keys are status code strings from TSR devices (e.g., 'DEVICE_ATEM_DISCONNECTED').
82+
*
83+
* Import status codes from 'timeline-state-resolver-types' for type safety.
84+
* Values can be:
85+
* - String templates using {{variable}} syntax for interpolation with context values
86+
* - Functions that receive DeviceStatusContext and return a custom message string
87+
* - Empty string to suppress the status message entirely
88+
*
89+
* @example
90+
* ```typescript
91+
* import { AtemStatusCode, CasparCGStatusCode } from 'timeline-state-resolver-types'
92+
*
93+
* deviceStatusMessages: {
94+
* // String template with placeholders
95+
* [AtemStatusCode.DISCONNECTED]: 'Vision mixer offline - check network to {{host}}',
96+
* [AtemStatusCode.PSU_FAULT]: 'PSU {{psuNumber}} needs attention',
97+
*
98+
* // Function for complex conditional logic
99+
* [CasparCGStatusCode.CHANNEL_ERROR]: (context) => {
100+
* const channel = context.channel as number
101+
* if (channel === 1) return 'Primary graphics output failed!'
102+
* return `Graphics channel ${channel} error on ${context.deviceName}`
103+
* },
104+
*
105+
* // Suppress a noisy message
106+
* [SomeStatusCode.NOISY_STATUS]: '',
107+
* }
108+
* ```
109+
*/
110+
deviceStatusMessages?: Record<string, string | DeviceStatusMessageFunction>
111+
59112
/** Returns the items used to build the baseline (default state) of a studio, this is the baseline used when there's no active rundown */
60113
getBaseline: (context: IStudioBaselineContext) => BlueprintResultStudioBaseline
61114

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,35 @@ import type { IBlueprintTriggeredActions } from '../triggers.js'
22
import type { BlueprintManifestBase, BlueprintManifestType } from './base.js'
33
import type { ICoreSystemApplyConfigContext } from '../context/systemApplyConfigContext.js'
44
import type { ICoreSystemSettings } from '@sofie-automation/shared-lib/dist/core/model/CoreSystemSettings'
5+
import type { SystemErrorCode } from '@sofie-automation/shared-lib/dist/systemErrorMessages'
6+
7+
// Re-export so blueprints can import from blueprints-integration
8+
export { SystemErrorCode } from '@sofie-automation/shared-lib/dist/systemErrorMessages'
59

610
export interface SystemBlueprintManifest extends BlueprintManifestBase {
711
blueprintType: BlueprintManifestType.SYSTEM
812

913
/** Translations connected to the studio (as stringified JSON) */
1014
translations?: string
1115

16+
/**
17+
* Alternate system error messages, to override the builtin ones produced by Sofie.
18+
* Keys are SystemErrorCode values (e.g., 'DATABASE_CONNECTION_LOST').
19+
*
20+
* Templates use {{variable}} syntax for interpolation with context values.
21+
*
22+
* @example
23+
* ```typescript
24+
* import { SystemErrorCode } from '@sofie-automation/blueprints-integration'
25+
*
26+
* systemErrorMessages: {
27+
* [SystemErrorCode.DATABASE_CONNECTION_LOST]: 'Database offline - contact IT support',
28+
* [SystemErrorCode.SERVICE_UNAVAILABLE]: 'Service {{serviceName}} is not responding',
29+
* }
30+
* ```
31+
*/
32+
systemErrorMessages?: Partial<Record<SystemErrorCode | string, string | undefined>>
33+
1234
/**
1335
* Apply the config by generating the data to be saved into the db.
1436
* This should be written to give a predictable and stable result, it can be called with the same config multiple times

0 commit comments

Comments
 (0)