Skip to content

Commit 59ce433

Browse files
authored
feat: TSR Device Feedback (#456)
1 parent 5ea9f2e commit 59ce433

76 files changed

Lines changed: 633 additions & 212 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import { TSRInput } from '../src/index.js'
2+
// import { DeviceType } from 'timeline-state-resolver-types'
23

34
export const input: TSRInput = {
45
settings: {
56
multiThreading: true,
67
multiThreadedResolver: false,
8+
// stateEvents: {
9+
// atem0: { type: DeviceType.ATEM, events: ['me.0.inputs'] },
10+
// },
711
},
812
}

packages/quick-tsr/src/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as chokidar from 'chokidar'
33
import * as fs from 'fs'
44
import * as _ from 'underscore'
55
import * as path from 'path'
6-
import { Mappings, TSRTimeline, DeviceOptionsAny, Datastore } from 'timeline-state-resolver-types'
6+
import { Mappings, TSRTimeline, DeviceOptionsAny, Datastore, TSREventTypesMap } from 'timeline-state-resolver-types'
77
import { TSRHandler } from './tsrHandler.js'
88

99
// import { TSRHandler } from './tsrHandler'
@@ -207,6 +207,13 @@ export interface TSRSettings {
207207
multiThreading?: boolean
208208
multiThreadedResolver?: boolean
209209
logCommandReports?: boolean
210+
stateEvents?: {
211+
[deviceId: string]: {
212+
[K in keyof TSREventTypesMap]: TSREventTypesMap[K] extends Record<string, unknown>
213+
? { type: K; events: (string & keyof TSREventTypesMap[K])[] }
214+
: never
215+
}[keyof TSREventTypesMap]
216+
}
210217
}
211218

212219
// ------------

packages/quick-tsr/src/tsrHandler.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
SlowFulfilledCommandInfo,
77
CasparCGDevice,
88
DevicesRegistry,
9+
type SomeTSRStateEvent,
910
} from 'timeline-state-resolver'
1011
import {
1112
DeviceOptionsAny,
@@ -136,6 +137,26 @@ export class TSRHandler {
136137
})
137138
}
138139

140+
if (tsrSettings.stateEvents && Object.keys(tsrSettings.stateEvents).length > 0) {
141+
const stateEvents = tsrSettings.stateEvents
142+
143+
this.tsr.connectionManager.on('connectionInitialised', (deviceId: string) => {
144+
const sub = stateEvents[deviceId]
145+
if (sub) {
146+
this.tsr.setDeviceEventSubscriptions(deviceId, sub.events).catch((e) => {
147+
console.error(`Failed to set event subscriptions for ${deviceId}:`, e)
148+
})
149+
}
150+
})
151+
152+
this.tsr.connectionManager.on('connectionEvent:stateEvent', (deviceId: string, events: SomeTSRStateEvent[]) => {
153+
for (const e of events) {
154+
const payloadStr = e.payload === null ? 'null' : JSON.stringify(e.payload)
155+
console.log(`State event [${deviceId}] ${e.event}: ${payloadStr}`)
156+
}
157+
})
158+
}
159+
139160
await this.tsr.init()
140161
}
141162
async destroy(): Promise<void> {

packages/timeline-state-resolver-api/src/device.ts

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,12 @@ export type CommandWithContext<TCommand, TContext> = {
3636
* API for use by the DeviceInstance to be able to use a device
3737
*/
3838
export interface Device<
39-
DeviceTypes extends { Options: any; Mappings: any; Actions: Record<string, any> | null },
39+
DeviceTypes extends {
40+
Options: any
41+
Mappings: any
42+
Actions: Record<string, any> | null
43+
Events?: Record<string, any>
44+
},
4045
DeviceState,
4146
Command extends CommandWithContext<any, any>,
4247
AddressState = void,
@@ -58,25 +63,11 @@ export interface Device<
5863

5964
// todo - add media objects
6065

61-
// From BaseDeviceAPI: -----------------------------------------------
66+
// Override types from BaseDeviceAPI: -----------------------------------------------
6267
convertTimelineStateToDeviceState(
6368
state: DeviceTimelineState,
6469
newMappings: Record<string, Mapping<DeviceTypes['Mappings']>>
6570
): DeviceState | { deviceState: DeviceState; addressStates: Record<string, AddressState> }
66-
diffStates(
67-
oldState: DeviceState | undefined,
68-
newState: DeviceState,
69-
mappings: Record<string, Mapping<DeviceTypes['Mappings']>>,
70-
time: number
71-
): Array<Command>
72-
sendCommand(command: Command): Promise<void>
73-
74-
applyAddressState?(state: DeviceState, address: string, addressState: AddressState): void
75-
diffAddressStates?(state1: AddressState, state2: AddressState | undefined): boolean
76-
diffAddressStates?(state1: AddressState | undefined, state2: AddressState): boolean
77-
addressStateReassertsControl?(oldState: AddressState, newState: AddressState | undefined): boolean
78-
addressStateReassertsControl?(oldState: AddressState | undefined, newState: AddressState): boolean
79-
// -------------------------------------------------------------------
8071
}
8172

8273
/**
@@ -121,6 +112,14 @@ export interface BaseDeviceAPI<DeviceState, AddressState, Command extends Comman
121112
*/
122113
addressStateReassertsControl?(oldState: AddressState, newState: AddressState | undefined): boolean
123114
addressStateReassertsControl?(oldState: AddressState | undefined, newState: AddressState): boolean
115+
116+
/**
117+
* Called when an address has been changed (i.e. the device is ahead of TSR).
118+
* This is called after the settle time has elapsed.
119+
* This is intended to be used to call `context.reportStateEvent()` to notify about the change.
120+
*/
121+
onAddressChanged?(address: string, isAhead: boolean): void
122+
124123
/**
125124
* This method takes 2 states and returns a set of device-commands that will
126125
* transition the device from oldState to newState.
@@ -171,7 +170,16 @@ export interface DeviceEvents {
171170
}
172171

173172
/** Various methods that the Devices can call */
174-
export interface DeviceContextAPI<DeviceState, AddressState = void> {
173+
export interface DeviceContextAPI<
174+
DeviceTypes extends {
175+
Options: any
176+
Mappings: any
177+
Actions: Record<string, any> | null
178+
Events?: Record<string, any>
179+
},
180+
DeviceState,
181+
AddressState = void,
182+
> {
175183
/** Human-readable name for this device */
176184
deviceName: string
177185

@@ -227,4 +235,16 @@ export interface DeviceContextAPI<DeviceState, AddressState = void> {
227235
recalcDiff: () => void
228236

229237
setAddressState: (address: string, state: AddressState) => void
238+
239+
/**
240+
* Report a state event to the consumer of TSR, to be listened on by `connectionEvent:stateEvent`
241+
* @param eventName The name of the event
242+
* @param payload The payload of the event. Note: this should be null to indicate a return to TSR controlled state.
243+
* @param isFromTimeline Indicate whether this event is for a state from the timeline
244+
*/
245+
reportStateEvent: <K extends string & keyof DeviceTypes['Events']>(
246+
eventName: K,
247+
payload: DeviceTypes['Events'] extends Record<string, unknown> ? DeviceTypes['Events'][K] : never,
248+
isFromTimeline: boolean
249+
) => void
230250
}

packages/timeline-state-resolver-api/src/tsr-api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export {
1414
} from 'timeline-state-resolver-types'
1515

1616
export interface DeviceEntry {
17-
deviceClass: new (context: DeviceContextAPI<any, any>) => Device<any, any, any, any>
17+
deviceClass: new (context: DeviceContextAPI<any, any, any>) => Device<any, any, any, any>
1818
canConnect: boolean
1919
deviceName: (deviceId: string, options: any) => string
2020
executionMode: (options: any) => 'salvo' | 'sequential'

packages/timeline-state-resolver-tools/bin/schema-types.mjs

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ if (isMainRepository) {
214214
indexFile += `export * from './action-schema.js'
215215
export * from './generic-ptz-actions.js'
216216
export * from './device-options.js'
217+
import type { DeviceType } from './device-options.js'
217218
`
218219
}
219220

@@ -413,12 +414,25 @@ ${actionDefinitions
413414
}
414415
deviceTypeEnum.push(deviceTypeId)
415416

417+
// Check for an optional events side-car file in integrations/{dir}/events.ts
418+
// Future: This needs some implementation thought for plugins
419+
const eventsFilePath = path.join(resolvedOutputPath, '..', 'integrations', dir, 'events.ts')
420+
const hasEventsFile = isMainRepository && (await fsExists(eventsFilePath))
421+
if (hasEventsFile) {
422+
output = `import type ${dirId}Events from '../integrations/${dir}/events.js'\n` + output
423+
}
424+
416425
output += `
417426
export interface ${dirId}DeviceTypes {
418427
Type: DeviceType.${deviceTypeId}
419428
Options: ${dirId}Options
420429
Mappings: SomeMapping${dirId}
421-
Actions: ${actionDefinitions.length > 0 ? `${dirId}ActionMethods` : 'null'}
430+
Actions: ${actionDefinitions.length > 0 ? `${dirId}ActionMethods` : 'null'}${
431+
hasEventsFile
432+
? `
433+
Events: ${dirId}Events`
434+
: ''
435+
}
422436
}
423437
`
424438

@@ -453,14 +467,29 @@ export type DeviceOptions${dirId} = DeviceOptionsBase<DeviceType.${deviceTypeId}
453467
await fs.writeFile(outputFilePath, output)
454468

455469
indexFile += `\nexport * from './${dir}'`
456-
indexFile += `\nimport type { ${someMappingName} } from './${dir}.js'`
470+
if (isMainRepository) {
471+
indexFile += `\nimport type { ${dirId}DeviceTypes } from './${dir}.js'`
472+
} else {
473+
indexFile += `\nimport type { ${someMappingName} } from './${dir}.js'`
474+
}
457475
indexFile += '\n'
458476
} else {
459477
if (await fsUnlink(outputFilePath)) console.log('Removed ' + outputFilePath)
460478
}
461479
}
462480

463-
if (baseMappingsTypes.length) {
481+
if (isMainRepository && deviceTypeEnum.length) {
482+
// Build TSRDeviceTypesMap as an augmentable interface (like DeviceOptionsMap)
483+
const deviceTypesMapEntries = deviceTypeEnum.map(
484+
(typeId, i) => `\t[DeviceType.${typeId}]: ${dirs[i] ? capitalise(dirs[i]) : typeId}DeviceTypes`
485+
)
486+
indexFile += `
487+
/**
488+
* A map of all built-in DeviceTypes.
489+
* TSR plugins can augment this interface to add their own device types:
490+
*/
491+
export interface TSRDeviceTypesMap {\n${deviceTypesMapEntries.join('\n')}\n}\n`
492+
} else if (baseMappingsTypes.length) {
464493
indexFile += `\nexport type TSRMappingOptions =\n\t| ${baseMappingsTypes.join('\n\t| ')}`
465494
}
466495

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { DeviceTypeExt, TSRDeviceTypesMap } from './index.js'
2+
3+
/**
4+
* A map of device types to their event types, derived from TSRDeviceTypesMap. This is used to type the events emitted by devices, to be listened on by `connectionEvent:stateEvent`
5+
*/
6+
export type TSREventTypesMap = {
7+
[K in keyof TSRDeviceTypesMap]: 'Events' extends keyof TSRDeviceTypesMap[K] ? TSRDeviceTypesMap[K]['Events'] : never
8+
}
9+
10+
/**
11+
* A union of all event types from all known device types.
12+
*/
13+
export type TSREventTypes = TSREventTypesMap[keyof TSREventTypesMap]
14+
15+
/**
16+
* Represents an event emitted by a device, to be listened on by `connectionEvent:stateEvent`
17+
* Note: the payload can be null to indicate a return to TSR controlled state.
18+
*/
19+
export type TSRStateEvent<TDeviceType extends DeviceTypeExt, TEventTypes extends Record<string, unknown>> = {
20+
[K in keyof TEventTypes]: {
21+
deviceId: string // eg atem0
22+
deviceType: TDeviceType
23+
event: K // the 'shared control address', or somethins like `me-program.1`
24+
payload: TEventTypes[K]
25+
/**
26+
* Indicate whether this event is for a state from the timeline
27+
*/
28+
isFromTimeline: boolean
29+
}
30+
}[keyof TEventTypes]
31+
32+
/**
33+
* A union of all possible state events from all devices, to be listened on by `connectionEvent:stateEvent`
34+
*/
35+
export type SomeTSRStateEvent<TDevice extends DeviceTypeExt = DeviceTypeExt> = TDevice extends keyof TSREventTypesMap
36+
? TSREventTypesMap[TDevice] extends Record<string, unknown>
37+
? TSRStateEvent<TDevice, TSREventTypesMap[TDevice]>
38+
: never
39+
: never
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
import { VIZMSEPlayoutItemContent } from './integrations/vizMSE.js'
1+
import { VIZMSEPlayoutItemContent } from './integrations/vizMSE/timeline.js'
22

33
export type ExpectedPlayoutItemContent = VIZMSEPlayoutItemContent

packages/timeline-state-resolver-types/src/generated/atem.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
55
* and re-run the "tsr-schema-types" tool to regenerate this file.
66
*/
7+
import type AtemEvents from '../integrations/atem/events.js'
78
import type { ActionExecutionResult } from '../actions.js'
89
import type { DeviceType } from './device-options.js'
910

@@ -118,4 +119,5 @@ export interface AtemDeviceTypes {
118119
Options: AtemOptions
119120
Mappings: SomeMappingAtem
120121
Actions: AtemActionMethods
122+
Events: AtemEvents
121123
}

0 commit comments

Comments
 (0)