@@ -2,7 +2,14 @@ import { Meteor } from 'meteor/meteor'
22import { check , Match } from '../lib/check'
33import _ from 'underscore'
44import { 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'
613import { stringifyObjects , literal } from '@sofie-automation/corelib/dist/lib'
714import { protectString , unprotectString } from '@sofie-automation/corelib/dist/protectedString'
815import { 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'
4149import { checkStudioExists } from '../optimizations'
4250import {
@@ -65,8 +73,121 @@ import bodyParser from 'koa-bodyparser'
6573import { assertConnectionHasOneOfPermissions } from '../security/auth'
6674import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio'
6775import { 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
6983const 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+
70191export 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 (
0 commit comments