Skip to content

Commit c0882da

Browse files
authored
Merge pull request #459 from bbc/rjmunro/improve-device-error-notifications
feat: add structured error codes and context to ActionExecutionResult
2 parents 59ce433 + 47392d5 commit c0882da

7 files changed

Lines changed: 255 additions & 20 deletions

File tree

packages/timeline-state-resolver-types/src/__tests__/__snapshots__/index.spec.ts.snap

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ exports[`index imports 1`] = `
1212
"AtemTransitionStyle",
1313
"BlendMode",
1414
"BorderBevel",
15+
"CasparCGActionErrorCode",
16+
"CasparCGActionErrorMessages",
1517
"CasparCGActions",
1618
"CasparCGScaleMode",
1719
"CasparCGStatusCode",
@@ -28,6 +30,8 @@ exports[`index imports 1`] = `
2830
"HTTPWatcherStatusCode",
2931
"HTTPWatcherStatusMessages",
3032
"HttpMethod",
33+
"HttpSendActionErrorCode",
34+
"HttpSendActionErrorMessages",
3135
"HttpSendActions",
3236
"HyperdeckActions",
3337
"HyperdeckStatusCode",

packages/timeline-state-resolver-types/src/actions.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,42 @@
11
import type { ITranslatableMessage } from './translations.js'
22

3+
/**
4+
* The result of executing a device action.
5+
*
6+
* On error, `response` is the pre-rendered human-readable fallback message. Consumers who want
7+
* to customise the message can use `code` and `context` to look up and interpolate a custom
8+
* template (e.g. from a blueprint's `deviceActionErrorMessages` map).
9+
*
10+
* Action error codes follow the pattern: ACTION_{DEVICETYPE}_{REASON}
11+
*
12+
* @example
13+
* // Device returns a structured error:
14+
* {
15+
* result: ActionExecutionResultCode.Error,
16+
* response: { key: 'CasparCG launcher host not configured' },
17+
* code: 'ACTION_CASPARCG_LAUNCHER_HOST_NOT_SET',
18+
* context: { deviceName: 'CasparCG 1', host: '192.168.1.10' },
19+
* }
20+
*
21+
* // Consumer applies a custom template:
22+
* interpolateTemplateString('{{deviceName}}: launcher host not set ({{host}})', result.context)
23+
*/
324
export interface ActionExecutionResult<ResultData = void> {
425
result: ActionExecutionResultCode
5-
/** Response message, intended to be displayed to a user */
26+
/** Pre-rendered human-readable response message, intended to be displayed to a user */
627
response?: ITranslatableMessage
728
/** Response data */
829
resultData?: ResultData
30+
/**
31+
* Structured error code for customisable messages - typically ACTION_{DEVICETYPE}_{REASON}.
32+
* Present on structured errors alongside `context`.
33+
*/
34+
code?: string
35+
/**
36+
* Context for custom message interpolation via interpolateTemplateString().
37+
* Present on structured errors alongside `code`.
38+
*/
39+
context?: Record<string, unknown>
940
}
1041

1142
export enum ActionExecutionResultCode {

packages/timeline-state-resolver-types/src/integrations/casparcg/timeline.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,89 @@ export const CasparCGStatusMessages: Record<CasparCGStatusCode, string> = {
4343
[CasparCGStatusCode.QUEUE_OVERFLOW]: 'CasparCG command queue overflow',
4444
}
4545

46+
/**
47+
* Action error codes for CasparCG device actions.
48+
* These codes can be customized in blueprints via deviceActionErrorMessages.
49+
*
50+
* Error codes follow the pattern: ACTION_CASPARCG_{ACTION}_{REASON}
51+
*/
52+
export const CasparCGActionErrorCode = {
53+
// ClearAllChannels errors
54+
/** ClearAllChannels: no connection to CasparCG */
55+
CLEAR_NO_CONNECTION: 'ACTION_CASPARCG_CLEAR_NO_CONNECTION',
56+
/** ClearAllChannels: failed to execute INFO command */
57+
CLEAR_INFO_FAILED: 'ACTION_CASPARCG_CLEAR_INFO_FAILED',
58+
/** ClearAllChannels: no channel data returned from INFO command */
59+
CLEAR_NO_CHANNELS: 'ACTION_CASPARCG_CLEAR_NO_CHANNELS',
60+
61+
// RestartServer errors
62+
/** RestartServer: device not initialized (no connection options) */
63+
RESTART_NOT_INITIALIZED: 'ACTION_CASPARCG_RESTART_NOT_INITIALIZED',
64+
/** RestartServer: launcher host not configured */
65+
RESTART_LAUNCHER_HOST_NOT_SET: 'ACTION_CASPARCG_RESTART_LAUNCHER_HOST_NOT_SET',
66+
/** RestartServer: launcher port not configured */
67+
RESTART_LAUNCHER_PORT_NOT_SET: 'ACTION_CASPARCG_RESTART_LAUNCHER_PORT_NOT_SET',
68+
/** RestartServer: launcher process not configured */
69+
RESTART_LAUNCHER_PROCESS_NOT_SET: 'ACTION_CASPARCG_RESTART_LAUNCHER_PROCESS_NOT_SET',
70+
/** RestartServer: launcher returned a non-200 HTTP status */
71+
RESTART_BAD_REPLY: 'ACTION_CASPARCG_RESTART_BAD_REPLY',
72+
/** RestartServer: network request to launcher failed */
73+
RESTART_REQUEST_FAILED: 'ACTION_CASPARCG_RESTART_REQUEST_FAILED',
74+
75+
// ListMedia errors
76+
/** ListMedia: device not initialized */
77+
LIST_NOT_INITIALIZED: 'ACTION_CASPARCG_LIST_NOT_INITIALIZED',
78+
/** ListMedia: CLS command returned an error */
79+
LIST_CLS_ERROR: 'ACTION_CASPARCG_LIST_CLS_ERROR',
80+
/** ListMedia: CLS command returned a non-200 response code */
81+
LIST_BAD_RESPONSE: 'ACTION_CASPARCG_LIST_BAD_RESPONSE',
82+
} as const
83+
84+
export type CasparCGActionErrorCode = (typeof CasparCGActionErrorCode)[keyof typeof CasparCGActionErrorCode]
85+
86+
/**
87+
* Default human-readable messages for each CasparCG action error code.
88+
* Used as fallback when no blueprint customization is present.
89+
*/
90+
export const CasparCGActionErrorMessages: Record<CasparCGActionErrorCode, string> = {
91+
[CasparCGActionErrorCode.CLEAR_NO_CONNECTION]: 'Cannot clear CasparCG channels: no connection',
92+
[CasparCGActionErrorCode.CLEAR_INFO_FAILED]: 'Cannot clear CasparCG channels: failed to retrieve channel info',
93+
[CasparCGActionErrorCode.CLEAR_NO_CHANNELS]: 'Cannot clear CasparCG channels: no channels found',
94+
95+
[CasparCGActionErrorCode.RESTART_NOT_INITIALIZED]: 'Cannot restart CasparCG: device not initialized',
96+
[CasparCGActionErrorCode.RESTART_LAUNCHER_HOST_NOT_SET]: 'Cannot restart CasparCG: launcher host not configured',
97+
[CasparCGActionErrorCode.RESTART_LAUNCHER_PORT_NOT_SET]: 'Cannot restart CasparCG: launcher port not configured',
98+
[CasparCGActionErrorCode.RESTART_LAUNCHER_PROCESS_NOT_SET]:
99+
'Cannot restart CasparCG: launcher process not configured',
100+
[CasparCGActionErrorCode.RESTART_BAD_REPLY]: 'CasparCG restart failed: launcher returned {{statusCode}} {{body}}',
101+
[CasparCGActionErrorCode.RESTART_REQUEST_FAILED]: 'CasparCG restart failed: {{errorMessage}}',
102+
103+
[CasparCGActionErrorCode.LIST_NOT_INITIALIZED]: 'Cannot list CasparCG media: device not initialized',
104+
[CasparCGActionErrorCode.LIST_CLS_ERROR]: 'CasparCG media list failed: {{errorMessage}}',
105+
[CasparCGActionErrorCode.LIST_BAD_RESPONSE]: 'CasparCG media list failed: server returned error {{responseCode}}',
106+
}
107+
108+
/**
109+
* Context data for each CasparCG action error code.
110+
* These fields are available for message template interpolation.
111+
*/
112+
export interface CasparCGActionErrorContextMap {
113+
[CasparCGActionErrorCode.CLEAR_NO_CONNECTION]: Record<string, never>
114+
[CasparCGActionErrorCode.CLEAR_INFO_FAILED]: Record<string, never>
115+
[CasparCGActionErrorCode.CLEAR_NO_CHANNELS]: Record<string, never>
116+
117+
[CasparCGActionErrorCode.RESTART_NOT_INITIALIZED]: Record<string, never>
118+
[CasparCGActionErrorCode.RESTART_LAUNCHER_HOST_NOT_SET]: Record<string, never>
119+
[CasparCGActionErrorCode.RESTART_LAUNCHER_PORT_NOT_SET]: Record<string, never>
120+
[CasparCGActionErrorCode.RESTART_LAUNCHER_PROCESS_NOT_SET]: Record<string, never>
121+
[CasparCGActionErrorCode.RESTART_BAD_REPLY]: { statusCode: number; body: string }
122+
[CasparCGActionErrorCode.RESTART_REQUEST_FAILED]: { errorMessage: string }
123+
124+
[CasparCGActionErrorCode.LIST_NOT_INITIALIZED]: Record<string, never>
125+
[CasparCGActionErrorCode.LIST_CLS_ERROR]: { errorMessage: string }
126+
[CasparCGActionErrorCode.LIST_BAD_RESPONSE]: { responseCode: number }
127+
}
128+
46129
export enum TimelineContentTypeCasparCg {
47130
// CasparCG-state
48131
MEDIA = 'media',

packages/timeline-state-resolver-types/src/integrations/httpSend/timeline.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,52 @@ export interface HTTPSendCommandContentExt extends Omit<HTTPSendCommandContent,
1010
}
1111

1212
export type TimelineContentHTTPRequest = TimelineContentHTTPSendBase & HTTPSendCommandContentExt
13+
14+
/**
15+
* Action error codes for HTTP Send device actions.
16+
* These codes can be customized in blueprints via deviceActionErrorMessages.
17+
*
18+
* Error codes follow the pattern: ACTION_HTTPSEND_{REASON}
19+
*/
20+
export const HttpSendActionErrorCode = {
21+
/** SendCommand action was called without a payload */
22+
MISSING_PAYLOAD: 'ACTION_HTTPSEND_MISSING_PAYLOAD',
23+
/** SendCommand action payload is missing a URL */
24+
MISSING_URL: 'ACTION_HTTPSEND_MISSING_URL',
25+
/** SendCommand action payload has an invalid HTTP method type */
26+
INVALID_TYPE: 'ACTION_HTTPSEND_INVALID_TYPE',
27+
/** SendCommand action payload is missing params */
28+
MISSING_PARAMS: 'ACTION_HTTPSEND_MISSING_PARAMS',
29+
/** SendCommand action payload has an invalid params type */
30+
INVALID_PARAMS_TYPE: 'ACTION_HTTPSEND_INVALID_PARAMS_TYPE',
31+
/** HTTP request failed with a network error */
32+
REQUEST_FAILED: 'ACTION_HTTPSEND_REQUEST_FAILED',
33+
} as const
34+
35+
export type HttpSendActionErrorCode = (typeof HttpSendActionErrorCode)[keyof typeof HttpSendActionErrorCode]
36+
37+
/**
38+
* Default human-readable messages for each HTTP Send action error code.
39+
* Used as fallback when no blueprint customization is present.
40+
*/
41+
export const HttpSendActionErrorMessages: Record<HttpSendActionErrorCode, string> = {
42+
[HttpSendActionErrorCode.MISSING_PAYLOAD]: 'Failed to send HTTP command: missing payload',
43+
[HttpSendActionErrorCode.MISSING_URL]: 'Failed to send HTTP command: missing URL',
44+
[HttpSendActionErrorCode.INVALID_TYPE]: 'Failed to send HTTP command: invalid HTTP method type',
45+
[HttpSendActionErrorCode.MISSING_PARAMS]: 'Failed to send HTTP command: missing params',
46+
[HttpSendActionErrorCode.INVALID_PARAMS_TYPE]: 'Failed to send HTTP command: invalid params type',
47+
[HttpSendActionErrorCode.REQUEST_FAILED]: 'HTTP request to {{url}} failed: {{errorMessage}}',
48+
}
49+
50+
/**
51+
* Context data for each HTTP Send action error code.
52+
* These fields are available for message template interpolation.
53+
*/
54+
export interface HttpSendActionErrorContextMap {
55+
[HttpSendActionErrorCode.MISSING_PAYLOAD]: Record<string, never>
56+
[HttpSendActionErrorCode.MISSING_URL]: Record<string, never>
57+
[HttpSendActionErrorCode.INVALID_TYPE]: { type: string }
58+
[HttpSendActionErrorCode.MISSING_PARAMS]: { url: string }
59+
[HttpSendActionErrorCode.INVALID_PARAMS_TYPE]: { paramsType: string }
60+
[HttpSendActionErrorCode.REQUEST_FAILED]: { url: string; errorMessage: string; errorCode?: string }
61+
}

packages/timeline-state-resolver/src/__tests__/__snapshots__/index.spec.ts.snap

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ exports[`index imports 1`] = `
1313
"AtemTransitionStyle",
1414
"BlendMode",
1515
"BorderBevel",
16+
"CasparCGActionErrorCode",
17+
"CasparCGActionErrorMessages",
1618
"CasparCGActions",
1719
"CasparCGDevice",
1820
"CasparCGScaleMode",
@@ -35,6 +37,8 @@ exports[`index imports 1`] = `
3537
"HTTPWatcherStatusCode",
3638
"HTTPWatcherStatusMessages",
3739
"HttpMethod",
40+
"HttpSendActionErrorCode",
41+
"HttpSendActionErrorMessages",
3842
"HttpSendActions",
3943
"HyperdeckActions",
4044
"HyperdeckDevice",

packages/timeline-state-resolver/src/integrations/casparCG/index.ts

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
CasparCGStatusCode,
3535
DeviceStatusDetail,
3636
DeviceStatusInput,
37+
CasparCGActionErrorCode,
3738
} from 'timeline-state-resolver-types'
3839
import { createCasparCGStatusDetail } from './messages.js'
3940

@@ -633,17 +634,29 @@ export class CasparCGDevice extends DeviceWithState<State, CasparCGDeviceTypes,
633634
if (!this._ccg?.connected) {
634635
return {
635636
result: ActionExecutionResultCode.Error,
636-
response: t('Cannot restart CasparCG without a connection'),
637+
code: CasparCGActionErrorCode.CLEAR_NO_CONNECTION,
638+
context: {},
639+
response: t('Cannot clear CasparCG channels: no connection'),
637640
}
638641
}
639642

640643
const { error, request } = await this._ccg.executeCommand({ command: Commands.Info, params: {} })
641644
if (error) {
642-
return { result: ActionExecutionResultCode.Error }
645+
return {
646+
result: ActionExecutionResultCode.Error,
647+
code: CasparCGActionErrorCode.CLEAR_INFO_FAILED,
648+
context: {},
649+
response: t('Cannot clear CasparCG channels: failed to get channel info'),
650+
}
643651
}
644652
const response = await request
645653
if (!response?.data[0]) {
646-
return { result: ActionExecutionResultCode.Error }
654+
return {
655+
result: ActionExecutionResultCode.Error,
656+
code: CasparCGActionErrorCode.CLEAR_NO_CHANNELS,
657+
context: {},
658+
response: t('Cannot clear CasparCG channels: no channels found'),
659+
}
647660
}
648661

649662
await Promise.all(
@@ -697,18 +710,35 @@ export class CasparCGDevice extends DeviceWithState<State, CasparCGDeviceTypes,
697710
*/
698711
private async restartCasparCG(): Promise<ActionExecutionResult> {
699712
if (!this.initOptions) {
700-
return { result: ActionExecutionResultCode.Error, response: t('CasparCGDevice._connectionOptions is not set!') }
713+
return {
714+
result: ActionExecutionResultCode.Error,
715+
code: CasparCGActionErrorCode.RESTART_NOT_INITIALIZED,
716+
context: {},
717+
response: t('Cannot restart CasparCG: device not initialized'),
718+
}
701719
}
702720
if (!this.initOptions.launcherHost) {
703-
return { result: ActionExecutionResultCode.Error, response: t('CasparCGDevice: config.launcherHost is not set!') }
721+
return {
722+
result: ActionExecutionResultCode.Error,
723+
code: CasparCGActionErrorCode.RESTART_LAUNCHER_HOST_NOT_SET,
724+
context: {},
725+
response: t('Cannot restart CasparCG: launcher host not configured'),
726+
}
704727
}
705728
if (!this.initOptions.launcherPort) {
706-
return { result: ActionExecutionResultCode.Error, response: t('CasparCGDevice: config.launcherPort is not set!') }
729+
return {
730+
result: ActionExecutionResultCode.Error,
731+
code: CasparCGActionErrorCode.RESTART_LAUNCHER_PORT_NOT_SET,
732+
context: {},
733+
response: t('Cannot restart CasparCG: launcher port not configured'),
734+
}
707735
}
708736
if (!this.initOptions.launcherProcess) {
709737
return {
710738
result: ActionExecutionResultCode.Error,
711-
response: t('CasparCGDevice: config.launcherProcess is not set!'),
739+
code: CasparCGActionErrorCode.RESTART_LAUNCHER_PROCESS_NOT_SET,
740+
context: {},
741+
response: t('Cannot restart CasparCG: launcher process not configured'),
712742
}
713743
}
714744

@@ -725,7 +755,9 @@ export class CasparCGDevice extends DeviceWithState<State, CasparCGDeviceTypes,
725755
} else {
726756
return {
727757
result: ActionExecutionResultCode.Error,
728-
response: t('Bad reply: [{{statusCode}}] {{body}}', {
758+
code: CasparCGActionErrorCode.RESTART_BAD_REPLY,
759+
context: { statusCode: response.statusCode, body: response.body },
760+
response: t('CasparCG restart failed: launcher returned {{statusCode}} {{body}}', {
729761
statusCode: response.statusCode,
730762
body: response.body,
731763
}),
@@ -735,8 +767,10 @@ export class CasparCGDevice extends DeviceWithState<State, CasparCGDeviceTypes,
735767
.catch((error) => {
736768
return {
737769
result: ActionExecutionResultCode.Error,
738-
response: t('{{message}}', {
739-
message: error.toString(),
770+
code: CasparCGActionErrorCode.RESTART_REQUEST_FAILED,
771+
context: { errorMessage: error.toString() },
772+
response: t('CasparCG restart failed: {{errorMessage}}', {
773+
errorMessage: error.toString(),
740774
}),
741775
}
742776
})
@@ -745,7 +779,9 @@ export class CasparCGDevice extends DeviceWithState<State, CasparCGDeviceTypes,
745779
if (!this._ccg) {
746780
return {
747781
result: ActionExecutionResultCode.Error,
748-
response: t('CasparCG device not initialized'),
782+
code: CasparCGActionErrorCode.LIST_NOT_INITIALIZED,
783+
context: {},
784+
response: t('Cannot list CasparCG media: device not initialized'),
749785
}
750786
}
751787
const result = await this._ccg.executeCommand(
@@ -757,7 +793,9 @@ export class CasparCGDevice extends DeviceWithState<State, CasparCGDeviceTypes,
757793
if (result.error)
758794
return {
759795
result: ActionExecutionResultCode.Error,
760-
response: t(`Error message from CasparCG: {{message}}`, { message: `${result.error}` }),
796+
code: CasparCGActionErrorCode.LIST_CLS_ERROR,
797+
context: { errorMessage: `${result.error}` },
798+
response: t(`CasparCG media list failed: {{errorMessage}}`, { errorMessage: `${result.error}` }),
761799
}
762800

763801
const request = await result.request
@@ -770,7 +808,11 @@ export class CasparCGDevice extends DeviceWithState<State, CasparCGDeviceTypes,
770808
} else {
771809
return {
772810
result: ActionExecutionResultCode.Error,
773-
response: t(`Error code {{code}} from CasparCG`, { code: request.responseCode }),
811+
code: CasparCGActionErrorCode.LIST_BAD_RESPONSE,
812+
context: { responseCode: request.responseCode },
813+
response: t(`CasparCG media list failed: server returned error {{responseCode}}`, {
814+
responseCode: request.responseCode,
815+
}),
774816
}
775817
}
776818
}

0 commit comments

Comments
 (0)