diff --git a/meteor/server/api/peripheralDevice.ts b/meteor/server/api/peripheralDevice.ts index be6e118a0a..caa1026462 100644 --- a/meteor/server/api/peripheralDevice.ts +++ b/meteor/server/api/peripheralDevice.ts @@ -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' @@ -37,6 +44,7 @@ import { PeripheralDeviceInitOptions, PeripheralDeviceStatusObject, TimelineTriggerTimeResult, + DeviceStatusError, } from '@sofie-automation/shared-lib/dist/peripheralDevice/peripheralDeviceAPI' import { checkStudioExists } from '../optimizations' import { @@ -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 { + try { + // Get the studio and its blueprint + const studio = (await Studios.findOneAsync(studioId, { + projection: { blueprintId: 1 }, + })) as Pick | 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 | 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, @@ -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( diff --git a/packages/blueprints-integration/src/api/studio.ts b/packages/blueprints-integration/src/api/studio.ts index ded3164c8a..830e95fae7 100644 --- a/packages/blueprints-integration/src/api/studio.ts +++ b/packages/blueprints-integration/src/api/studio.ts @@ -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, @@ -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 + /** 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 diff --git a/packages/blueprints-integration/src/api/system.ts b/packages/blueprints-integration/src/api/system.ts index f052c4e1b1..739f99f143 100644 --- a/packages/blueprints-integration/src/api/system.ts +++ b/packages/blueprints-integration/src/api/system.ts @@ -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 @@ -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> + /** * 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 diff --git a/packages/corelib/src/ErrorMessageResolver.ts b/packages/corelib/src/ErrorMessageResolver.ts new file mode 100644 index 0000000000..7bfc5c09b9 --- /dev/null +++ b/packages/corelib/src/ErrorMessageResolver.ts @@ -0,0 +1,147 @@ +import { BlueprintId } from './dataModel/Ids.js' +import { generateTranslation } from './lib.js' +import { ITranslatableMessage, wrapTranslatableMessageFromBlueprints } from './TranslatableMessage.js' +import { SystemErrorCode } from '@sofie-automation/shared-lib/dist/systemErrorMessages' +import type { DeviceErrorContext, DeviceErrorMessageFunction } from '@sofie-automation/blueprints-integration' + +// Re-export for consumers of corelib +export type { DeviceErrorContext, DeviceErrorMessageFunction } + +/** + * Default error messages for system errors + */ +const DEFAULT_SYSTEM_ERROR_MESSAGES: Record = { + [SystemErrorCode.DATABASE_CONNECTION_LOST]: generateTranslation('Database connection to {{database}} lost'), + [SystemErrorCode.INSUFFICIENT_RESOURCES]: generateTranslation( + 'Insufficient {{resource}}: {{available}} available, {{required}} required' + ), + [SystemErrorCode.SERVICE_UNAVAILABLE]: generateTranslation('Service {{service}} unavailable: {{reason}}'), +} + +/** + * Device error messages map - can contain string templates or functions. + * Functions are evaluated at runtime, strings use {{variable}} interpolation. + */ +export type DeviceErrorMessages = Record + +/** + * System error messages map - string templates only. + * Uses {{variable}} interpolation. + */ +export type SystemErrorMessages = Partial> + +/** + * Resolves error messages with blueprint customizations. + * Works with runtime blueprint manifests (evaluated at setStatus time). + * + * For device errors, the default message templates come from TSR devices. + * Studio blueprints can override these by providing custom templates or functions in deviceErrorMessages. + * + * For system errors, System blueprints can override the defaults in systemErrorMessages. + * + * @example + * ```typescript + * import { AtemErrorCode, AtemErrorMessages } from 'timeline-state-resolver-types' + * + * // Get blueprint manifest at runtime (from evalBlueprint or similar) + * const studioManifest = await getBlueprintManifest(studioBlueprintId) + * + * // Create resolver for device errors + * const resolver = new ErrorMessageResolver( + * studioManifest.deviceErrorMessages + * ) + * + * // Resolve device error - pass default message from TSR + * const message = resolver.getDeviceErrorMessage( + * AtemErrorCode.DISCONNECTED, + * { deviceName: 'Vision Mixer', host: '192.168.1.10' }, + * AtemErrorMessages[AtemErrorCode.DISCONNECTED] + * ) + * ``` + */ +export class ErrorMessageResolver { + readonly #blueprintId: BlueprintId | undefined + readonly #deviceErrorMessages: DeviceErrorMessages | undefined + readonly #systemErrorMessages: SystemErrorMessages | undefined + + constructor( + blueprintId: BlueprintId | undefined, + deviceErrorMessages?: DeviceErrorMessages | undefined, + systemErrorMessages?: SystemErrorMessages | undefined + ) { + this.#blueprintId = blueprintId + this.#deviceErrorMessages = deviceErrorMessages + this.#systemErrorMessages = systemErrorMessages + } + + /** + * Get a translatable message for a device error. + * + * @param errorCode - The error code string from TSR (e.g., 'DEVICE_ATEM_DISCONNECTED') + * @param context - Context values for message interpolation (deviceName, deviceId, and TSR error context) + * @param defaultMessage - The default message template from TSR (e.g., 'ATEM disconnected') + * @returns ITranslatableMessage with the resolved message, or null if suppressed + */ + getDeviceErrorMessage( + errorCode: string, + context: DeviceErrorContext, + defaultMessage: string + ): ITranslatableMessage | null { + // Check blueprint messages from Studio blueprint manifest + if (this.#deviceErrorMessages) { + const blueprintMessage = this.#deviceErrorMessages[errorCode] + + // Evaluate if function or use as string template + const result = typeof blueprintMessage === 'function' ? blueprintMessage(context) : blueprintMessage + + if (result === '') { + // Empty string means suppress the message + return null + } + + if (result) { + // Custom message from blueprint - use as-is (no prefix) + return { key: result, args: context } + } + + // undefined or not found - fall through to default + } + + // Use default message from TSR with device name prefix + return { + key: `{{deviceName}}: ${defaultMessage}`, + args: context, + } + } + + /** + * Get a translatable message for a system error. + * Uses customizations from the System blueprint if available. + */ + getSystemErrorMessage( + errorCode: SystemErrorCode | string, + args: { [k: string]: unknown } + ): ITranslatableMessage | null { + // Check blueprint messages from System blueprint manifest + if (this.#systemErrorMessages) { + const blueprintMessage = this.#systemErrorMessages[errorCode] + + if (blueprintMessage === '') { + // Empty string means suppress the message + return null + } + + if (blueprintMessage) { + return this.#blueprintId + ? wrapTranslatableMessageFromBlueprints({ key: blueprintMessage, args }, [this.#blueprintId]) + : { key: blueprintMessage, args } + } + } + + // Use default message + return { + key: DEFAULT_SYSTEM_ERROR_MESSAGES[errorCode as SystemErrorCode]?.key ?? errorCode, + args, + } + } +} diff --git a/packages/corelib/src/__tests__/ErrorMessageResolver.test.ts b/packages/corelib/src/__tests__/ErrorMessageResolver.test.ts new file mode 100644 index 0000000000..79624091f7 --- /dev/null +++ b/packages/corelib/src/__tests__/ErrorMessageResolver.test.ts @@ -0,0 +1,304 @@ +import { ErrorMessageResolver } from '../ErrorMessageResolver.js' +import { SystemErrorCode } from '@sofie-automation/shared-lib/dist/systemErrorMessages' +import { protectString } from '../protectedString.js' + +// Mock device error codes (these would come from TSR in production) +const MockDeviceErrorCode = { + HTTP_TIMEOUT: 'DEVICE_HTTP_TIMEOUT', + CASPARCG_DISCONNECTED: 'DEVICE_CASPARCG_DISCONNECTED', + CASPARCG_FILE_NOT_FOUND: 'DEVICE_CASPARCG_FILE_NOT_FOUND', +} as const + +// Mock default messages (these would come from TSR in production) +const MockDeviceErrorMessages = { + [MockDeviceErrorCode.HTTP_TIMEOUT]: '{{deviceName}}: HTTP request to {{url}} timed out after {{timeout}}ms', + [MockDeviceErrorCode.CASPARCG_DISCONNECTED]: '{{deviceName}}: CasparCG server at {{host}}:{{port}} disconnected', + [MockDeviceErrorCode.CASPARCG_FILE_NOT_FOUND]: '{{deviceName}}: File "{{fileName}}" not found on CasparCG server', +} + +describe('ErrorMessageResolver', () => { + describe('Device errors', () => { + it('returns default message from TSR when no blueprint provided', () => { + const resolver = new ErrorMessageResolver(undefined, undefined, undefined) + const message = resolver.getDeviceErrorMessage( + MockDeviceErrorCode.HTTP_TIMEOUT, + { + deviceName: 'Graphics Server', + url: 'http://graphics/api', + timeout: 5000, + }, + MockDeviceErrorMessages[MockDeviceErrorCode.HTTP_TIMEOUT] + ) + + expect(message).toBeTruthy() + expect(message?.key).toBe('{{deviceName}}: HTTP request to {{url}} timed out after {{timeout}}ms') + expect(message?.args).toMatchObject({ + deviceName: 'Graphics Server', + url: 'http://graphics/api', + timeout: 5000, + }) + }) + + it('returns blueprint custom message when provided', () => { + const resolver = new ErrorMessageResolver( + protectString('studioBlueprint123'), + { + [MockDeviceErrorCode.HTTP_TIMEOUT]: 'Graphics system {{deviceName}} not responding', + }, + undefined + ) + + const message = resolver.getDeviceErrorMessage( + MockDeviceErrorCode.HTTP_TIMEOUT, + { + deviceName: 'Graphics Server', + url: 'http://graphics/api', + timeout: 5000, + }, + MockDeviceErrorMessages[MockDeviceErrorCode.HTTP_TIMEOUT] + ) + + expect(message).toBeTruthy() + expect(message?.key).toBe('Graphics system {{deviceName}} not responding') + expect(message?.args?.deviceName).toBe('Graphics Server') + }) + + it('returns null when blueprint provides empty string (suppression)', () => { + const resolver = new ErrorMessageResolver( + protectString('studioBlueprint123'), + { + [MockDeviceErrorCode.HTTP_TIMEOUT]: '', + }, + undefined + ) + + const message = resolver.getDeviceErrorMessage( + MockDeviceErrorCode.HTTP_TIMEOUT, + { + deviceName: 'Graphics Server', + }, + MockDeviceErrorMessages[MockDeviceErrorCode.HTTP_TIMEOUT] + ) + + expect(message).toBeNull() + }) + + it('evaluates function-based error messages', () => { + const resolver = new ErrorMessageResolver( + protectString('studioBlueprint123'), + { + [MockDeviceErrorCode.HTTP_TIMEOUT]: (context) => { + const timeout = context.timeout as number + if (timeout > 10000) { + return `${context.deviceName}: Critical timeout (${timeout}ms) - check network` + } + return `${context.deviceName}: Request timeout` + }, + }, + undefined + ) + + // Test with small timeout + const shortMessage = resolver.getDeviceErrorMessage( + MockDeviceErrorCode.HTTP_TIMEOUT, + { + deviceName: 'Graphics Server', + timeout: 5000, + }, + MockDeviceErrorMessages[MockDeviceErrorCode.HTTP_TIMEOUT] + ) + + expect(shortMessage?.key).toBe('Graphics Server: Request timeout') + + // Test with long timeout + const longMessage = resolver.getDeviceErrorMessage( + MockDeviceErrorCode.HTTP_TIMEOUT, + { + deviceName: 'Graphics Server', + timeout: 15000, + }, + MockDeviceErrorMessages[MockDeviceErrorCode.HTTP_TIMEOUT] + ) + + expect(longMessage?.key).toBe('Graphics Server: Critical timeout (15000ms) - check network') + }) + + it('suppresses message when function returns empty string', () => { + const resolver = new ErrorMessageResolver( + protectString('studioBlueprint123'), + { + [MockDeviceErrorCode.HTTP_TIMEOUT]: () => '', // Always suppress + }, + undefined + ) + + const message = resolver.getDeviceErrorMessage( + MockDeviceErrorCode.HTTP_TIMEOUT, + { + deviceName: 'Graphics Server', + }, + MockDeviceErrorMessages[MockDeviceErrorCode.HTTP_TIMEOUT] + ) + + expect(message).toBeNull() + }) + + it('returns default for CasparCG errors', () => { + const resolver = new ErrorMessageResolver(undefined, undefined, undefined) + const message = resolver.getDeviceErrorMessage( + MockDeviceErrorCode.CASPARCG_FILE_NOT_FOUND, + { + deviceName: 'CasparCG1', + fileName: 'video.mp4', + }, + MockDeviceErrorMessages[MockDeviceErrorCode.CASPARCG_FILE_NOT_FOUND] + ) + + expect(message).toBeTruthy() + expect(message?.key).toContain('File') + expect(message?.key).toContain('not found') + }) + + it('falls back to TSR default when blueprint has no customization for that error', () => { + const resolver = new ErrorMessageResolver( + protectString('studioBlueprint123'), + { + [MockDeviceErrorCode.HTTP_TIMEOUT]: 'Custom timeout message', + }, + undefined + ) + + // Ask for a different error that has no customization + const message = resolver.getDeviceErrorMessage( + MockDeviceErrorCode.CASPARCG_DISCONNECTED, + { + deviceName: 'CasparCG1', + host: 'localhost', + }, + MockDeviceErrorMessages[MockDeviceErrorCode.CASPARCG_DISCONNECTED] + ) + + expect(message).toBeTruthy() + expect(message?.key).toContain('CasparCG') + }) + }) + + describe('System errors', () => { + it('returns default message when no blueprint provided', () => { + const resolver = new ErrorMessageResolver(undefined, undefined, undefined) + const message = resolver.getSystemErrorMessage(SystemErrorCode.DATABASE_CONNECTION_LOST, { + database: 'MongoDB', + }) + + expect(message).toBeTruthy() + expect(message?.key).toContain('Database') + expect(message?.args?.database).toBe('MongoDB') + }) + + it('returns blueprint custom message', () => { + const resolver = new ErrorMessageResolver( + protectString('systemBlueprint123'), + undefined, + { + [SystemErrorCode.DATABASE_CONNECTION_LOST]: 'System database offline - please wait', + } + ) + + const message = resolver.getSystemErrorMessage(SystemErrorCode.DATABASE_CONNECTION_LOST, { + database: 'MongoDB', + }) + + expect(message?.key).toBe('System database offline - please wait') + }) + + it('suppresses message with empty string', () => { + const resolver = new ErrorMessageResolver( + protectString('systemBlueprint123'), + undefined, + { + [SystemErrorCode.INSUFFICIENT_RESOURCES]: '', + } + ) + + const message = resolver.getSystemErrorMessage(SystemErrorCode.INSUFFICIENT_RESOURCES, { + resource: 'memory', + }) + + expect(message).toBeNull() + }) + }) + + describe('Combined blueprints', () => { + it('resolves device errors and system errors with same blueprint ID', () => { + // Note: In practice, device errors use Studio blueprint ID and system errors use System blueprint ID + // But the resolver doesn't enforce this - it just associates the ID with any messages it generates + const resolver = new ErrorMessageResolver( + protectString('blueprint123'), + { + [MockDeviceErrorCode.HTTP_TIMEOUT]: 'Custom device error', + }, + { + [SystemErrorCode.DATABASE_CONNECTION_LOST]: 'Custom system error', + } + ) + + const deviceMessage = resolver.getDeviceErrorMessage( + MockDeviceErrorCode.HTTP_TIMEOUT, + { deviceName: 'Server' }, + MockDeviceErrorMessages[MockDeviceErrorCode.HTTP_TIMEOUT] + ) + + const systemMessage = resolver.getSystemErrorMessage(SystemErrorCode.DATABASE_CONNECTION_LOST, { + database: 'MongoDB', + }) + + expect(deviceMessage?.key).toBe('Custom device error') + expect(systemMessage?.key).toBe('Custom system error') + }) + + it('includes correct blueprint namespace for messages', () => { + const resolver = new ErrorMessageResolver( + protectString('blueprint123'), + { + [MockDeviceErrorCode.HTTP_TIMEOUT]: 'Custom device error', + }, + { + [SystemErrorCode.DATABASE_CONNECTION_LOST]: 'Custom system error', + } + ) + + const deviceMessage = resolver.getDeviceErrorMessage( + MockDeviceErrorCode.HTTP_TIMEOUT, + { deviceName: 'Server' }, + MockDeviceErrorMessages[MockDeviceErrorCode.HTTP_TIMEOUT] + ) + + const systemMessage = resolver.getSystemErrorMessage(SystemErrorCode.DATABASE_CONNECTION_LOST, { + database: 'MongoDB', + }) + + // Both messages should have the same blueprint namespace + expect(deviceMessage?.namespaces).toContain('blueprint_blueprint123') + expect(systemMessage?.namespaces).toContain('blueprint_blueprint123') + }) + + it('does not add namespace when no blueprint ID provided', () => { + const resolver = new ErrorMessageResolver( + undefined, + { + [MockDeviceErrorCode.HTTP_TIMEOUT]: 'Custom device error', + }, + undefined + ) + + const message = resolver.getDeviceErrorMessage( + MockDeviceErrorCode.HTTP_TIMEOUT, + { deviceName: 'Server' }, + MockDeviceErrorMessages[MockDeviceErrorCode.HTTP_TIMEOUT] + ) + + // No namespace when no blueprint ID + expect(message?.namespaces).toBeUndefined() + }) + }) +}) diff --git a/packages/corelib/src/index.ts b/packages/corelib/src/index.ts index c441bb34c6..805e4b524c 100644 --- a/packages/corelib/src/index.ts +++ b/packages/corelib/src/index.ts @@ -2,3 +2,12 @@ export { Timecode } from 'timecode' export { MOS } from '@sofie-automation/shared-lib/dist/mos' + +// Error message resolver +export { ErrorMessageResolver } from './ErrorMessageResolver.js' +export type { + DeviceErrorContext, + DeviceErrorMessageFunction, + DeviceErrorMessages, + SystemErrorMessages, +} from './ErrorMessageResolver.js' diff --git a/packages/documentation/docs/for-developers/for-blueprint-developers/error-message-customization.md b/packages/documentation/docs/for-developers/for-blueprint-developers/error-message-customization.md new file mode 100644 index 0000000000..f30228a9f6 --- /dev/null +++ b/packages/documentation/docs/for-developers/for-blueprint-developers/error-message-customization.md @@ -0,0 +1,214 @@ +--- +sidebar_position: 12 +--- + +# Error Message Customization + +Blueprints can customize the error messages displayed to users in the Sofie UI. This allows you to replace technical error messages with human-friendly ones that are relevant to your broadcast environment. + +## Overview + +Sofie displays error notifications from several sources: + +- **Device errors** - from TSR devices (ATEM, CasparCG, vMix, etc.) - customized via **Studio Blueprint** +- **Package errors** - from the Package Manager (media files, thumbnails) - customized via **ShowStyle Blueprint** +- **System errors** - from Sofie Core itself - customized via **System Blueprint** + +Each error type is customized in a different blueprint type, matching where the errors originate. + +## Device Error Messages (Studio Blueprint) + +Device errors come from TSR (Timeline State Resolver) integrations. Customize them in your **Studio Blueprint**: + +```typescript +import { StudioBlueprintManifest } from '@sofie-automation/blueprints-integration' +import { AtemErrorCode, CasparCGErrorCode } from 'timeline-state-resolver-types' + +export const manifest: StudioBlueprintManifest = { + // ... other manifest properties ... + + deviceErrorMessages: { + // Simple string template with placeholders + [AtemErrorCode.DISCONNECTED]: 'Vision mixer offline - check network to {{host}}', + + // Use {{deviceName}} for the configured device name + [CasparCGErrorCode.DISCONNECTED]: '{{deviceName}}: Graphics server offline ({{host}}:{{port}})', + + // Empty string suppresses the error entirely + [AtemErrorCode.SOME_NOISY_WARNING]: '', + }, +} +``` + +### Using Placeholders + +Error messages support `{{placeholder}}` syntax for dynamic values. Common placeholders include: + +- `{{deviceName}}` - The configured name of the device in Sofie +- `{{deviceId}}` - The internal device ID +- Additional context from the specific error (e.g., `{{host}}`, `{{port}}`, `{{channel}}`) + +### Function-Based Messages + +For complex logic, use a function instead of a string. Functions can return: +- A **string** - Use this as the custom message +- **`undefined`** - Fall back to the default TSR message +- **`''`** (empty string) - Suppress the message entirely + +```typescript +import { DeviceErrorContext } from '@sofie-automation/blueprints-integration' + +deviceErrorMessages: { + [CasparCGErrorCode.CHANNEL_ERROR]: (context: DeviceErrorContext) => { + const channel = context.channel as number + if (channel === 1) return 'Primary graphics output failed!' + if (channel === 2) return 'Secondary graphics output failed!' + return `Graphics channel ${channel} error on ${context.deviceName}` + }, + + // Return undefined to use the default TSR message + [SomeErrorCode.COMPLEX_ERROR]: (context) => { + if (context.isExpected) return undefined // Fall back to default + return `Unexpected error on ${context.deviceName}` + }, + + // Return empty string to suppress + [SomeErrorCode.NOISY_WARNING]: (context) => { + if (context.severity === 'low') return '' // Suppress low severity + return `Warning on ${context.deviceName}` + }, +} +``` + +### Available Error Codes + +Import error codes from `timeline-state-resolver-types` for type safety: + +```typescript +import { + AtemErrorCode, + CasparCGErrorCode, + VmixErrorCode, + OBSErrorCode, + // ... other device error codes +} from 'timeline-state-resolver-types' +``` + +Each device type exports its own error codes. Check the TSR documentation or source code for the complete list. + +## Package Status Messages (ShowStyle Blueprint) + +Package Manager messages are customized in your **ShowStyle Blueprint**: + +```typescript +import { ShowStyleBlueprintManifest, PackageStatusMessage } from '@sofie-automation/blueprints-integration' + +export const manifest: ShowStyleBlueprintManifest = { + // ... other manifest properties ... + + packageStatusMessages: { + [PackageStatusMessage.MISSING_FILE_PATH]: 'Media file path not configured - check ingest settings', + [PackageStatusMessage.SCAN_FAILED]: 'Could not scan media file - check file permissions', + [PackageStatusMessage.FILE_NOT_FOUND]: '', // Suppress this message + }, +} +``` + +## System Error Messages (System Blueprint) + +System-level errors from Sofie Core are customized in your **System Blueprint**: + +```typescript +import { SystemBlueprintManifest, SystemErrorCode } from '@sofie-automation/blueprints-integration' + +export const manifest: SystemBlueprintManifest = { + // ... other manifest properties ... + + systemErrorMessages: { + [SystemErrorCode.DATABASE_CONNECTION_LOST]: 'Database offline - contact IT support', + [SystemErrorCode.SERVICE_UNAVAILABLE]: 'Service {{serviceName}} is not responding', + }, +} +``` + +## Message Resolution + +When an error occurs, Sofie resolves the message as follows: + +1. **Check blueprint customization** - Look for matching error code in the appropriate blueprint +2. **If function** - Call it with the error context: + - Returns `undefined` → Use default TSR message + - Returns `''` (empty string) → Suppress the message + - Returns a string → Use that as the message +3. **If string** - Interpolate placeholders with context values +4. **If empty string `''`** - Suppress the message entirely +5. **If not found** - Use the default message from TSR/Sofie + +Device error resolution happens **server-side** when the error is received, ensuring consistent messages across all connected clients. + +```mermaid +flowchart TD + A[Device reports error with code & context] --> B{Blueprint has customization?} + B -->|Yes, function| C[Call function with context] + B -->|Yes, string| D{Empty string?} + B -->|No| E[Use default TSR message] + C --> F{Function returns?} + D -->|Yes| H[Suppress message] + D -->|No| G[Interpolate & display] + F -->|undefined| E + F -->|empty string| H + F -->|string| G + E --> G +``` + +## Complete Example + +Here's a complete Studio Blueprint example: + +```typescript +import { + StudioBlueprintManifest, + DeviceErrorContext, +} from '@sofie-automation/blueprints-integration' +import { AtemErrorCode, CasparCGErrorCode } from 'timeline-state-resolver-types' + +export const deviceErrorMessages: StudioBlueprintManifest['deviceErrorMessages'] = { + // Simple string with placeholders + [AtemErrorCode.DISCONNECTED]: '🎬 Vision mixer offline - check ATEM at {{host}}', + [AtemErrorCode.PSU_FAULT]: '⚠️ ATEM PSU {{psuNumber}} fault - check hardware', + + // Graphics server with host:port + [CasparCGErrorCode.DISCONNECTED]: '{{deviceName}}: Graphics offline ({{host}}:{{port}})', + + // Function for complex logic + [CasparCGErrorCode.CHANNEL_ERROR]: (context: DeviceErrorContext) => { + const channel = context.channel as number + const channelNames: Record = { + 1: 'Program graphics', + 2: 'Preview graphics', + 3: 'DSK graphics', + } + const name = channelNames[channel] || `Channel ${channel}` + return `${name} failed on ${context.deviceName}` + }, +} + +export const manifest: StudioBlueprintManifest = { + blueprintType: 'studio', + // ... other required properties ... + deviceErrorMessages, +} +``` + +## Tips + +- **Keep messages actionable** - Tell users what to do, not just what's wrong +- **Use emoji sparingly** - They can help draw attention to critical errors +- **Test with real devices** - Disconnect devices to verify your messages appear correctly +- **Check TSR source** - Device error codes and their context values are defined in TSR integrations +- **Use functions for complex cases** - Conditional logic, pluralization, severity-based filtering + +## See Also + +- [TSR Device Integrations](https://github.com/nrkno/sofie-timeline-state-resolver) - Device error codes +- [Demo Blueprints](https://github.com/nrkno/sofie-demo-blueprints) - Working examples diff --git a/packages/shared-lib/src/peripheralDevice/peripheralDeviceAPI.ts b/packages/shared-lib/src/peripheralDevice/peripheralDeviceAPI.ts index 92f4bae3ad..52e4cfa661 100644 --- a/packages/shared-lib/src/peripheralDevice/peripheralDeviceAPI.ts +++ b/packages/shared-lib/src/peripheralDevice/peripheralDeviceAPI.ts @@ -1,6 +1,10 @@ import { DeviceConfigManifest } from '../core/deviceConfigManifest.js' import { PeripheralDeviceId, RundownPlaylistId, PartInstanceId, PieceInstanceId } from '../core/model/Ids.js' import { StatusCode } from '../lib/status.js' +import { DeviceStatusError } from 'timeline-state-resolver-types' + +// Re-export for use in UI components +export { DeviceStatusError } export interface PartPlaybackCallbackData { rundownPlaylistId: RundownPlaylistId @@ -76,6 +80,12 @@ export type PlayoutChangedResult = { export interface PeripheralDeviceStatusObject { statusCode: StatusCode messages?: Array + /** + * Structured errors from TSR devices for blueprint customization. + * When present, blueprints can provide custom translations for these error codes. + * The messages array is still populated for backward compatibility. + */ + errors?: Array } // Note The actual type of a device is determined by the Category, Type and SubType export enum PeripheralDeviceCategory { diff --git a/packages/shared-lib/src/systemErrorMessages.ts b/packages/shared-lib/src/systemErrorMessages.ts new file mode 100644 index 0000000000..63d63aa765 --- /dev/null +++ b/packages/shared-lib/src/systemErrorMessages.ts @@ -0,0 +1,40 @@ +/** + * System-level error codes for customizable error messages. + * + * These are for core Sofie system errors. + * Each error code documents the variables available in the translation context. + */ +export enum SystemErrorCode { + /** + * Database connection lost + * Variables: database + */ + DATABASE_CONNECTION_LOST = 'SYSTEM_DB_CONNECTION_LOST', + + /** + * System resources running low + * Variables: resource, available, required + */ + INSUFFICIENT_RESOURCES = 'SYSTEM_INSUFFICIENT_RESOURCES', + + /** + * Service unavailable + * Variables: service, reason + */ + SERVICE_UNAVAILABLE = 'SYSTEM_SERVICE_UNAVAILABLE', +} + +export interface SystemErrorContexts { + [SystemErrorCode.DATABASE_CONNECTION_LOST]: { + database: string + } + [SystemErrorCode.INSUFFICIENT_RESOURCES]: { + resource: string + available: unknown + required: unknown + } + [SystemErrorCode.SERVICE_UNAVAILABLE]: { + service: string + reason: string + } +} diff --git a/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx b/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx index c876d3d082..43e0e7b98f 100644 --- a/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownNotifier.tsx @@ -809,7 +809,7 @@ class RundownViewNotifier extends WithManagedTracker { if (!device.connected) { return t('Device {{deviceName}} is disconnected', { deviceName: device.name }) } - return `${device.name}: ` + (device.status.messages || ['']).join(', ') + return (device.status.messages || ['']).join(', ') } }