Skip to content
Draft
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
138 changes: 137 additions & 1 deletion meteor/server/api/peripheralDevice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ import { Meteor } from 'meteor/meteor'
import { check, Match } from '../lib/check'
import _ from 'underscore'
import { PeripheralDeviceType, PeripheralDevice } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice'
import { PeripheralDeviceCommands, PeripheralDevices, Rundowns, Studios, UserActionsLog } from '../collections'
import {
PeripheralDeviceCommands,
PeripheralDevices,
Rundowns,
Studios,
UserActionsLog,
Blueprints,
} from '../collections'
import { stringifyObjects, literal } from '@sofie-automation/corelib/dist/lib'
import { protectString, unprotectString } from '@sofie-automation/corelib/dist/protectedString'
import { getCurrentTime } from '../lib/lib'
Expand Down Expand Up @@ -37,6 +44,7 @@ import {
PeripheralDeviceInitOptions,
PeripheralDeviceStatusObject,
TimelineTriggerTimeResult,
DeviceStatusError,
} from '@sofie-automation/shared-lib/dist/peripheralDevice/peripheralDeviceAPI'
import { checkStudioExists } from '../optimizations'
import {
Expand Down Expand Up @@ -65,8 +73,109 @@ import bodyParser from 'koa-bodyparser'
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 { ErrorMessageResolver } from '@sofie-automation/corelib'
import { interpollateTranslation } from '@sofie-automation/corelib/dist/TranslatableMessage'
import { Blueprint } from '@sofie-automation/corelib/dist/dataModel/Blueprint'
import { StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids'

const apmNamespace = 'peripheralDevice'

/**
* Resolve device error messages using the Studio blueprint's deviceErrorMessages.
* This allows blueprints to customize error messages shown to operators.
*
* @param studioId - The studio ID to look up the blueprint
* @param deviceName - The peripheral device name (shorter than TSR's internal name)
* @param deviceId - The peripheral device ID
* @param errors - Structured errors from TSR
* @param defaultMessages - The original messages from TSR (used as fallback)
*/
async function resolveDeviceErrorMessages(
studioId: StudioId,
deviceName: string,
deviceId: PeripheralDeviceId,
errors: DeviceStatusError[],
defaultMessages: string[]
): Promise<string[]> {
try {
// Get the studio and its blueprint
const studio = (await Studios.findOneAsync(studioId, {
projection: { blueprintId: 1 },
})) as Pick<DBStudio, 'blueprintId'> | undefined

if (!studio?.blueprintId) {
// No blueprint, return empty (caller will use original messages)
return []
}

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

if (!blueprint) {
return []
}

// Evaluate the blueprint to get the manifest with deviceErrorMessages
const blueprintManifest = evalBlueprint(blueprint) as StudioBlueprintManifest

logger.debug(
`Blueprint ${blueprint._id} deviceErrorMessages keys: ${Object.keys(blueprintManifest.deviceErrorMessages ?? {}).join(', ')}`
)

if (!blueprintManifest.deviceErrorMessages) {
// Blueprint doesn't define any custom error messages
logger.debug(`Blueprint ${blueprint._id} has no deviceErrorMessages`)
return []
}

// Create resolver with the blueprint's error messages
const resolver = new ErrorMessageResolver(
blueprint._id,
blueprintManifest.deviceErrorMessages,
undefined // No system error messages
)

// Resolve each error
const resolvedMessages: string[] = []
for (let i = 0; i < errors.length; i++) {
const error = errors[i]
// Use the original TSR message as fallback, or error code if not available
const defaultMessage = defaultMessages[i] ?? error.code

logger.debug(`Resolving error code: ${error.code}, context: ${JSON.stringify(error.context)}`)
const message = resolver.getDeviceErrorMessage(
error.code,
{
...error.context,
// Override with peripheral device info (TSR might have longer names)
deviceName,
deviceId: unprotectString(deviceId),
},
defaultMessage
)

if (message) {
// Interpolate the message template with context values
const interpolated = interpollateTranslation(message.key, message.args)
logger.debug(`Resolved message for ${error.code}: ${interpolated}`)
resolvedMessages.push(interpolated)
} else {
logger.debug(`Message suppressed for ${error.code}`)
}
}

return resolvedMessages
} catch (e) {
// Log error but don't fail - fall back to original messages
logger.error(`Error resolving device error messages: ${e}`)
return []
}
}

export namespace ServerPeripheralDeviceAPI {
export async function initialize(
context: MethodContext,
Expand Down Expand Up @@ -203,6 +312,33 @@ export namespace ServerPeripheralDeviceAPI {
throw new Meteor.Error(400, 'device status code is not known')
}

// Resolve error messages using Studio blueprint if structured errors are present
// Child devices (like casparcg0) don't have studioAndConfigId directly - get it from parent
let studioId = peripheralDevice.studioAndConfigId?.studioId
if (!studioId && peripheralDevice.parentDeviceId) {
const parentDevice = await PeripheralDevices.findOneAsync(peripheralDevice.parentDeviceId, {
projection: { studioAndConfigId: 1 },
})
studioId = parentDevice?.studioAndConfigId?.studioId
}

logger.info(
`Device ${deviceId} setStatus: errors=${status.errors?.length ?? 'undefined'}, messages=${status.messages?.length ?? 'undefined'}, studioId=${studioId ?? 'none'}`
)
if (status.errors && status.errors.length > 0 && studioId) {
const resolvedMessages = await resolveDeviceErrorMessages(
studioId,
peripheralDevice.name,
peripheralDevice._id,
status.errors,
status.messages ?? []
)
if (resolvedMessages.length > 0) {
// Replace the pre-formatted messages with blueprint-customized ones
status.messages = resolvedMessages
}
}

// check if we have to update something:
if (!_.isEqual(status, peripheralDevice.status)) {
logger.info(
Expand Down
53 changes: 53 additions & 0 deletions packages/blueprints-integration/src/api/studio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,26 @@ import type { MosGatewayConfig } from '@sofie-automation/shared-lib/dist/generat
import type { PlayoutGatewayConfig } from '@sofie-automation/shared-lib/dist/generated/PlayoutGatewayConfigTypes'
import type { LiveStatusGatewayConfig } from '@sofie-automation/shared-lib/dist/generated/LiveStatusGatewayOptionsTypes'

/**
* Context provided to device error message functions.
* Contains the device name, device ID, and any additional context from the TSR error.
*/
export interface DeviceErrorContext {
/** Human-readable name of the device */
deviceName: string
/** Internal device ID */
deviceId?: string
/** Additional context values from the TSR error (e.g., host, port, channel, etc.) */
[key: string]: unknown
}

/**
* A function that receives device error context and returns a custom error message.
* Return `undefined` to fall back to the default TSR message.
* Return an empty string `''` to suppress the message entirely.
*/
export type DeviceErrorMessageFunction = (context: DeviceErrorContext) => string | undefined

export interface StudioBlueprintManifest<
TRawConfig = IBlueprintConfig,
TProcessedConfig = unknown,
Expand All @@ -52,6 +72,39 @@ export interface StudioBlueprintManifest<
/** Translations connected to the studio (as stringified JSON) */
translations?: string

/**
* Alternate device error messages, to override the default messages from TSR devices.
* Keys are error code strings from TSR devices (e.g., 'DEVICE_ATEM_DISCONNECTED').
*
* Import 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 DeviceErrorContext and return a custom message string
* - Empty string to suppress the error message entirely
*
* @example
* ```typescript
* import { AtemErrorCode, CasparCGErrorCode } from 'timeline-state-resolver-types'
*
* deviceErrorMessages: {
* // String template with placeholders
* [AtemErrorCode.DISCONNECTED]: 'Vision mixer offline - check network to {{host}}',
* [AtemErrorCode.PSU_FAULT]: 'PSU {{psuNumber}} needs attention',
*
* // Function for complex conditional logic
* [CasparCGErrorCode.CHANNEL_ERROR]: (context) => {
* const channel = context.channel as number
* if (channel === 1) return 'Primary graphics output failed!'
* return `Graphics channel ${channel} error on ${context.deviceName}`
* },
*
* // Suppress a noisy error
* [SomeErrorCode.NOISY_ERROR]: '',
* }
* ```
*/
deviceErrorMessages?: Record<string, string | DeviceErrorMessageFunction | 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
22 changes: 22 additions & 0 deletions packages/blueprints-integration/src/api/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import type { MigrationStepSystem } from '../migrations.js'
import type { BlueprintManifestBase, BlueprintManifestType } from './base.js'
import type { ICoreSystemApplyConfigContext } from '../context/systemApplyConfigContext.js'
import type { ICoreSystemSettings } from '@sofie-automation/shared-lib/dist/core/model/CoreSystemSettings'
import type { SystemErrorCode } from '@sofie-automation/shared-lib/dist/systemErrorMessages'

// Re-export so blueprints can import from blueprints-integration
export { SystemErrorCode } from '@sofie-automation/shared-lib/dist/systemErrorMessages'

export interface SystemBlueprintManifest extends BlueprintManifestBase {
blueprintType: BlueprintManifestType.SYSTEM
Expand All @@ -15,6 +19,24 @@ export interface SystemBlueprintManifest extends BlueprintManifestBase {
/** Translations connected to the studio (as stringified JSON) */
translations?: string

/**
* Alternate system error messages, to override the builtin ones produced by Sofie.
* Keys are SystemErrorCode values (e.g., 'DATABASE_CONNECTION_LOST').
*
* Templates use {{variable}} syntax for interpolation with context values.
*
* @example
* ```typescript
* import { SystemErrorCode } from '@sofie-automation/blueprints-integration'
*
* systemErrorMessages: {
* [SystemErrorCode.DATABASE_CONNECTION_LOST]: 'Database offline - contact IT support',
* [SystemErrorCode.SERVICE_UNAVAILABLE]: 'Service {{serviceName}} is not responding',
* }
* ```
*/
systemErrorMessages?: Partial<Record<SystemErrorCode | string, string | undefined>>

/**
* Apply the config by generating the data to be saved into the db.
* This should be written to give a predictable and stable result, it can be called with the same config multiple times
Expand Down
Loading
Loading