Skip to content
4 changes: 4 additions & 0 deletions packages/quick-tsr/input/settings.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { TSRInput } from '../src/index.js'
// import { DeviceType } from 'timeline-state-resolver-types'

export const input: TSRInput = {
settings: {
multiThreading: true,
multiThreadedResolver: false,
// stateEvents: {
// atem0: { type: DeviceType.ATEM, events: ['me.0.inputs'] },
// },
},
}
9 changes: 8 additions & 1 deletion packages/quick-tsr/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as chokidar from 'chokidar'
import * as fs from 'fs'
import * as _ from 'underscore'
import * as path from 'path'
import { Mappings, TSRTimeline, DeviceOptionsAny, Datastore } from 'timeline-state-resolver-types'
import { Mappings, TSRTimeline, DeviceOptionsAny, Datastore, TSREventTypesMap } from 'timeline-state-resolver-types'
import { TSRHandler } from './tsrHandler.js'

// import { TSRHandler } from './tsrHandler'
Expand Down Expand Up @@ -207,6 +207,13 @@ export interface TSRSettings {
multiThreading?: boolean
multiThreadedResolver?: boolean
logCommandReports?: boolean
stateEvents?: {
[deviceId: string]: {
[K in keyof TSREventTypesMap]: TSREventTypesMap[K] extends Record<string, unknown>
? { type: K; events: (string & keyof TSREventTypesMap[K])[] }
: never
}[keyof TSREventTypesMap]
}
}

// ------------
Expand Down
21 changes: 21 additions & 0 deletions packages/quick-tsr/src/tsrHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
SlowFulfilledCommandInfo,
CasparCGDevice,
DevicesRegistry,
type SomeTSRStateEvent,
} from 'timeline-state-resolver'
import {
DeviceOptionsAny,
Expand Down Expand Up @@ -136,6 +137,26 @@ export class TSRHandler {
})
}

if (tsrSettings.stateEvents && Object.keys(tsrSettings.stateEvents).length > 0) {
const stateEvents = tsrSettings.stateEvents

this.tsr.connectionManager.on('connectionInitialised', (deviceId: string) => {
const sub = stateEvents[deviceId]
if (sub) {
this.tsr.setDeviceEventSubscriptions(deviceId, sub.events).catch((e) => {
console.error(`Failed to set event subscriptions for ${deviceId}:`, e)
})
}
})

this.tsr.connectionManager.on('connectionEvent:stateEvent', (deviceId: string, events: SomeTSRStateEvent[]) => {
for (const e of events) {
const payloadStr = e.payload === null ? 'null' : JSON.stringify(e.payload)
console.log(`State event [${deviceId}] ${e.event}: ${payloadStr}`)
}
})
}

await this.tsr.init()
}
async destroy(): Promise<void> {
Expand Down
54 changes: 37 additions & 17 deletions packages/timeline-state-resolver-api/src/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ export type CommandWithContext<TCommand, TContext> = {
* API for use by the DeviceInstance to be able to use a device
*/
export interface Device<
DeviceTypes extends { Options: any; Mappings: any; Actions: Record<string, any> | null },
DeviceTypes extends {
Options: any
Mappings: any
Actions: Record<string, any> | null
Events?: Record<string, any>
},
DeviceState,
Command extends CommandWithContext<any, any>,
AddressState = void,
Expand All @@ -58,25 +63,11 @@ export interface Device<

// todo - add media objects

// From BaseDeviceAPI: -----------------------------------------------
// Override types from BaseDeviceAPI: -----------------------------------------------
convertTimelineStateToDeviceState(
state: DeviceTimelineState,
newMappings: Record<string, Mapping<DeviceTypes['Mappings']>>
): DeviceState | { deviceState: DeviceState; addressStates: Record<string, AddressState> }
diffStates(
oldState: DeviceState | undefined,
newState: DeviceState,
mappings: Record<string, Mapping<DeviceTypes['Mappings']>>,
time: number
): Array<Command>
sendCommand(command: Command): Promise<void>

applyAddressState?(state: DeviceState, address: string, addressState: AddressState): void
diffAddressStates?(state1: AddressState, state2: AddressState | undefined): boolean
diffAddressStates?(state1: AddressState | undefined, state2: AddressState): boolean
addressStateReassertsControl?(oldState: AddressState, newState: AddressState | undefined): boolean
addressStateReassertsControl?(oldState: AddressState | undefined, newState: AddressState): boolean
// -------------------------------------------------------------------
}

/**
Expand Down Expand Up @@ -121,6 +112,14 @@ export interface BaseDeviceAPI<DeviceState, AddressState, Command extends Comman
*/
addressStateReassertsControl?(oldState: AddressState, newState: AddressState | undefined): boolean
addressStateReassertsControl?(oldState: AddressState | undefined, newState: AddressState): boolean

/**
* Called when an address has been changed (i.e. the device is ahead of TSR).
* This is called after the settle time has elapsed.
* This is intended to be used to call `context.reportStateEvent()` to notify about the change.
*/
onAddressChanged?(address: string, isAhead: boolean): void

/**
* This method takes 2 states and returns a set of device-commands that will
* transition the device from oldState to newState.
Expand Down Expand Up @@ -171,7 +170,16 @@ export interface DeviceEvents {
}

/** Various methods that the Devices can call */
export interface DeviceContextAPI<DeviceState, AddressState = void> {
export interface DeviceContextAPI<
DeviceTypes extends {
Options: any
Mappings: any
Actions: Record<string, any> | null
Events?: Record<string, any>
},
DeviceState,
AddressState = void,
> {
/** Human-readable name for this device */
deviceName: string

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

setAddressState: (address: string, state: AddressState) => void

/**
* Report a state event to the consumer of TSR, to be listened on by `connectionEvent:stateEvent`
* @param eventName The name of the event
* @param payload The payload of the event. Note: this should be null to indicate a return to TSR controlled state.
* @param isFromTimeline Indicate whether this event is for a state from the timeline
*/
reportStateEvent: <K extends string & keyof DeviceTypes['Events']>(
eventName: K,
payload: DeviceTypes['Events'] extends Record<string, unknown> ? DeviceTypes['Events'][K] : never,
isFromTimeline: boolean
) => void
}
2 changes: 1 addition & 1 deletion packages/timeline-state-resolver-api/src/tsr-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export {
} from 'timeline-state-resolver-types'

export interface DeviceEntry {
deviceClass: new (context: DeviceContextAPI<any, any>) => Device<any, any, any, any>
deviceClass: new (context: DeviceContextAPI<any, any, any>) => Device<any, any, any, any>
canConnect: boolean
deviceName: (deviceId: string, options: any) => string
executionMode: (options: any) => 'salvo' | 'sequential'
Expand Down
35 changes: 32 additions & 3 deletions packages/timeline-state-resolver-tools/bin/schema-types.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ if (isMainRepository) {
indexFile += `export * from './action-schema.js'
export * from './generic-ptz-actions.js'
export * from './device-options.js'
import type { DeviceType } from './device-options.js'
`
}

Expand Down Expand Up @@ -413,12 +414,25 @@ ${actionDefinitions
}
deviceTypeEnum.push(deviceTypeId)

// Check for an optional events side-car file in integrations/{dir}/events.ts
// Future: This needs some implementation thought for plugins
const eventsFilePath = path.join(resolvedOutputPath, '..', 'integrations', dir, 'events.ts')
const hasEventsFile = isMainRepository && (await fsExists(eventsFilePath))
if (hasEventsFile) {
output = `import type ${dirId}Events from '../integrations/${dir}/events.js'\n` + output
}

output += `
export interface ${dirId}DeviceTypes {
Type: DeviceType.${deviceTypeId}
Options: ${dirId}Options
Mappings: SomeMapping${dirId}
Actions: ${actionDefinitions.length > 0 ? `${dirId}ActionMethods` : 'null'}
Actions: ${actionDefinitions.length > 0 ? `${dirId}ActionMethods` : 'null'}${
hasEventsFile
? `
Events: ${dirId}Events`
: ''
}
}
`

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

indexFile += `\nexport * from './${dir}'`
indexFile += `\nimport type { ${someMappingName} } from './${dir}.js'`
if (isMainRepository) {
indexFile += `\nimport type { ${dirId}DeviceTypes } from './${dir}.js'`
} else {
indexFile += `\nimport type { ${someMappingName} } from './${dir}.js'`
}
indexFile += '\n'
} else {
if (await fsUnlink(outputFilePath)) console.log('Removed ' + outputFilePath)
}
}

if (baseMappingsTypes.length) {
if (isMainRepository && deviceTypeEnum.length) {
// Build TSRDeviceTypesMap as an augmentable interface (like DeviceOptionsMap)
const deviceTypesMapEntries = deviceTypeEnum.map(
(typeId, i) => `\t[DeviceType.${typeId}]: ${dirs[i] ? capitalise(dirs[i]) : typeId}DeviceTypes`
)
Comment thread
Julusian marked this conversation as resolved.
indexFile += `
/**
* A map of all built-in DeviceTypes.
* TSR plugins can augment this interface to add their own device types:
*/
export interface TSRDeviceTypesMap {\n${deviceTypesMapEntries.join('\n')}\n}\n`
} else if (baseMappingsTypes.length) {
indexFile += `\nexport type TSRMappingOptions =\n\t| ${baseMappingsTypes.join('\n\t| ')}`
}

Expand Down
39 changes: 39 additions & 0 deletions packages/timeline-state-resolver-types/src/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { DeviceTypeExt, TSRDeviceTypesMap } from './index.js'

/**
* 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`
*/
export type TSREventTypesMap = {
[K in keyof TSRDeviceTypesMap]: 'Events' extends keyof TSRDeviceTypesMap[K] ? TSRDeviceTypesMap[K]['Events'] : never
}

/**
* A union of all event types from all known device types.
*/
export type TSREventTypes = TSREventTypesMap[keyof TSREventTypesMap]

/**
* Represents an event emitted by a device, to be listened on by `connectionEvent:stateEvent`
* Note: the payload can be null to indicate a return to TSR controlled state.
*/
export type TSRStateEvent<TDeviceType extends DeviceTypeExt, TEventTypes extends Record<string, unknown>> = {
[K in keyof TEventTypes]: {
deviceId: string // eg atem0
deviceType: TDeviceType
event: K // the 'shared control address', or somethins like `me-program.1`
payload: TEventTypes[K]
/**
* Indicate whether this event is for a state from the timeline
*/
isFromTimeline: boolean
}
}[keyof TEventTypes]

/**
* A union of all possible state events from all devices, to be listened on by `connectionEvent:stateEvent`
*/
export type SomeTSRStateEvent<TDevice extends DeviceTypeExt = DeviceTypeExt> = TDevice extends keyof TSREventTypesMap
? TSREventTypesMap[TDevice] extends Record<string, unknown>
? TSRStateEvent<TDevice, TSREventTypesMap[TDevice]>
: never
: never
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { VIZMSEPlayoutItemContent } from './integrations/vizMSE.js'
import { VIZMSEPlayoutItemContent } from './integrations/vizMSE/timeline.js'

export type ExpectedPlayoutItemContent = VIZMSEPlayoutItemContent
2 changes: 2 additions & 0 deletions packages/timeline-state-resolver-types/src/generated/atem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
* and re-run the "tsr-schema-types" tool to regenerate this file.
*/
import type AtemEvents from '../integrations/atem/events.js'
import type { ActionExecutionResult } from '../actions.js'
import type { DeviceType } from './device-options.js'

Expand Down Expand Up @@ -118,4 +119,5 @@ export interface AtemDeviceTypes {
Options: AtemOptions
Mappings: SomeMappingAtem
Actions: AtemActionMethods
Events: AtemEvents
}
Loading
Loading