From 4b4d2f63fb16cc66fb348c4c276803ea430d457b Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 26 Jun 2024 09:02:12 +0100 Subject: [PATCH] feat: refactor tricaster device SOFIE-2497 (#336) --- .../timeline-state-resolver/src/conductor.ts | 14 +- .../tricaster/__tests__/index.spec.ts | 87 +++++------ .../__tests__/tricasterStateDiffer.spec.ts | 2 + .../src/integrations/tricaster/index.ts | 144 +++++++----------- .../tricaster/triCasterCommands.ts | 4 +- .../tricaster/triCasterStateDiffer.ts | 5 +- .../src/service/devices.ts | 8 + 7 files changed, 104 insertions(+), 160 deletions(-) diff --git a/packages/timeline-state-resolver/src/conductor.ts b/packages/timeline-state-resolver/src/conductor.ts index fc289b856..b42a109de 100644 --- a/packages/timeline-state-resolver/src/conductor.ts +++ b/packages/timeline-state-resolver/src/conductor.ts @@ -35,6 +35,7 @@ import { DeviceOptionsLawo, DeviceOptionsSofieChef, DeviceOptionsPharos, + DeviceOptionsTriCaster, } from 'timeline-state-resolver-types' import { DoOnTime } from './devices/doOnTime' @@ -50,7 +51,6 @@ import { SingularLiveDevice, DeviceOptionsSingularLiveInternal } from './integra import { VMixDevice, DeviceOptionsVMixInternal } from './integrations/vmix' import { VizMSEDevice, DeviceOptionsVizMSEInternal } from './integrations/vizMSE' import { TelemetricsDevice } from './integrations/telemetrics' -import { TriCasterDevice, DeviceOptionsTriCasterInternal } from './integrations/tricaster' import { DeviceOptionsMultiOSCInternal, MultiOSCMessageDevice } from './integrations/multiOsc' import { BaseRemoteDeviceIntegration, RemoteDeviceInstance } from './service/remoteDeviceInstance' import type { ImplementedServiceDeviceTypes } from './service/devices' @@ -555,15 +555,6 @@ export class Conductor extends EventEmitter { getCurrentTime, threadedClassOptions ) - case DeviceType.TRICASTER: - return DeviceContainer.create( - '../../dist/integrations/tricaster/index.js', - 'TriCasterDevice', - deviceId, - deviceOptions, - getCurrentTime, - threadedClassOptions - ) case DeviceType.MULTI_OSC: return DeviceContainer.create( '../../dist/integrations/multiOsc/index.js', @@ -586,6 +577,7 @@ export class Conductor extends EventEmitter { case DeviceType.SHOTOKU: case DeviceType.SOFIE_CHEF: case DeviceType.TCPSEND: + case DeviceType.TRICASTER: case DeviceType.QUANTEL: { ensureIsImplementedAsService(deviceOptions.type) @@ -1509,7 +1501,7 @@ export type DeviceOptionsAnyInternal = | DeviceOptionsShotoku | DeviceOptionsVizMSEInternal | DeviceOptionsTelemetrics - | DeviceOptionsTriCasterInternal + | DeviceOptionsTriCaster | DeviceOptionsMultiOSC function removeParentFromState( diff --git a/packages/timeline-state-resolver/src/integrations/tricaster/__tests__/index.spec.ts b/packages/timeline-state-resolver/src/integrations/tricaster/__tests__/index.spec.ts index fb0eec2ce..afbd57057 100644 --- a/packages/timeline-state-resolver/src/integrations/tricaster/__tests__/index.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/tricaster/__tests__/index.spec.ts @@ -1,5 +1,4 @@ import { EventEmitter } from 'eventemitter3' -import { MockTime } from '../../../__tests__/mockTime' import { DeviceType, SomeMappingTricaster, @@ -7,14 +6,16 @@ import { TimelineContentTypeTriCaster, TimelineContentTriCasterME, Mapping, + Timeline, + TSRTimelineContent, } from 'timeline-state-resolver-types' import { TriCasterDevice } from '..' import { TriCasterConnectionEvents, TriCasterConnection } from '../triCasterConnection' import { literal } from '../../../lib' import { wrapIntoResolvedInstance } from './helpers' +import { getDeviceContext } from '../../__tests__/testlib' const MOCK_CONNECT = jest.fn() -const MOCK_SEND = jest.fn(async () => Promise.resolve()) const MOCK_CLOSE = jest.fn() jest.mock('../triCasterConnection', () => ({ @@ -24,17 +25,14 @@ jest.mock('../triCasterConnection', () => ({ })) describe('TriCasterDevice', () => { - const mockTime = new MockTime() beforeEach(() => { - mockTime.init() ;(TriCasterConnection as jest.Mock).mockClear() MOCK_CONNECT.mockClear() - MOCK_SEND.mockClear() MOCK_CLOSE.mockClear() }) test('resolves init when connected', async () => { - const device = createTriCasterDevice(mockTime) + const device = createTriCasterDevice() const initResult = await device.init({ host: 'testhost', @@ -51,14 +49,13 @@ describe('TriCasterDevice', () => { }) test('sends commands', async () => { - const device = createTriCasterDevice(mockTime) + const device = createTriCasterDevice() await device.init({ host: 'testhost', port: 56789, }) - expect(MOCK_SEND).not.toHaveBeenCalled() const mappings = { tc_me0_0: literal>({ device: DeviceType.TRICASTER, @@ -70,53 +67,39 @@ describe('TriCasterDevice', () => { }), } - device.handleState({ time: 11000, layers: {}, nextEvents: [] }, mappings) - await mockTime.advanceTimeToTicks(11010) - - // check that initial commands are sent after connection - // the number of them is not that relevant, but they have to only affect the mapped resource - expect(MOCK_SEND).toHaveBeenCalled() - expect(MOCK_SEND.mock.calls.filter((call) => (call as any)[0].target.startsWith('main')).length).toEqual( - MOCK_SEND.mock.calls.length - ) - MOCK_SEND.mockClear() - - device.handleState( - { - time: 12000, - layers: { - tc_me0_0: wrapIntoResolvedInstance({ - layer: 'tc_me0_0', - enable: { while: '1' }, - id: 't0', - content: { - deviceType: DeviceType.TRICASTER, - type: TimelineContentTypeTriCaster.ME, - me: { programInput: 'input2', previewInput: 'input3', transitionEffect: 5, transitionDuration: 20 }, - }, - }), - }, - nextEvents: [], + const state1: Timeline.TimelineState = { time: 11000, layers: {}, nextEvents: [] } + const tricasterState1 = device.convertTimelineStateToDeviceState(state1, mappings) + const commands1 = device.diffStates(undefined, tricasterState1, mappings) + expect(commands1).not.toHaveLength(0) + + const state2: Timeline.TimelineState = { + time: 12000, + layers: { + tc_me0_0: wrapIntoResolvedInstance({ + layer: 'tc_me0_0', + enable: { while: '1' }, + id: 't0', + content: { + deviceType: DeviceType.TRICASTER, + type: TimelineContentTypeTriCaster.ME, + me: { programInput: 'input2', previewInput: 'input3', transitionEffect: 5, transitionDuration: 20 }, + }, + }), }, - mappings - ) - await mockTime.advanceTimeToTicks(12010) - expect(MOCK_SEND).toHaveBeenCalledTimes(4) - expect(MOCK_SEND).toHaveBeenNthCalledWith(1, { target: 'main', name: '_set_mix_effect_bin_index', value: 5 }) - expect(MOCK_SEND).toHaveBeenNthCalledWith(2, { target: 'main', name: '_speed', value: 20 }) - expect(MOCK_SEND).toHaveBeenNthCalledWith(3, { target: 'main_b', name: '_row_named_input', value: 'input2' }) - expect(MOCK_SEND).toHaveBeenNthCalledWith(4, { target: 'main', name: '_auto' }) + nextEvents: [], + } + const tricasterState2 = device.convertTimelineStateToDeviceState(state2, mappings) + const commands2 = device.diffStates(tricasterState1, tricasterState2, mappings) + expect(commands2).toHaveLength(4) + expect(commands2[0].command).toMatchObject({ target: 'main', name: '_set_mix_effect_bin_index', value: 5 }) + expect(commands2[1].command).toMatchObject({ target: 'main', name: '_speed', value: 20 }) + expect(commands2[2].command).toMatchObject({ target: 'main_b', name: '_row_named_input', value: 'input2' }) + expect(commands2[3].command).toMatchObject({ target: 'main', name: '_auto' }) }) }) -function createTriCasterDevice(mockTime): TriCasterDevice { - return new TriCasterDevice( - 'tc0', - { - type: DeviceType.TRICASTER, - }, - mockTime.getCurrentTime - ) +function createTriCasterDevice(): TriCasterDevice { + return new TriCasterDevice(getDeviceContext()) } class TriCasterConnectionMock extends EventEmitter { @@ -139,7 +122,5 @@ class TriCasterConnectionMock extends EventEmitter { ) }) - send = MOCK_SEND - close = MOCK_CLOSE } diff --git a/packages/timeline-state-resolver/src/integrations/tricaster/__tests__/tricasterStateDiffer.spec.ts b/packages/timeline-state-resolver/src/integrations/tricaster/__tests__/tricasterStateDiffer.spec.ts index f22151d54..10d70f4b1 100644 --- a/packages/timeline-state-resolver/src/integrations/tricaster/__tests__/tricasterStateDiffer.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/tricaster/__tests__/tricasterStateDiffer.spec.ts @@ -731,11 +731,13 @@ describe('TriCasterStateDiffer.getCommandsToAchieveState', () => { expect(commands[0]).toEqual({ command: { target: 'main_dsk2', name: '_select_named_input', value: 'input2' }, timelineObjId: 't3', + context: '', temporalPriority: 0, }) expect(commands[1]).toEqual({ command: { target: 'main_dsk2', name: '_value', value: 1 }, timelineObjId: 't2', + context: '', temporalPriority: 0, }) }) diff --git a/packages/timeline-state-resolver/src/integrations/tricaster/index.ts b/packages/timeline-state-resolver/src/integrations/tricaster/index.ts index 617941a27..ca883f8e9 100644 --- a/packages/timeline-state-resolver/src/integrations/tricaster/index.ts +++ b/packages/timeline-state-resolver/src/integrations/tricaster/index.ts @@ -1,7 +1,3 @@ -import * as _ from 'underscore' -import { DeviceWithState, DeviceStatus, StatusCode } from './../../devices/device' -import { DoOnTime, SendMode } from '../../devices/doOnTime' - import { DeviceType, Mappings, @@ -11,17 +7,27 @@ import { Timeline, TSRTimelineContent, Mapping, + ActionExecutionResult, + StatusCode, + DeviceStatus, } from 'timeline-state-resolver-types' import { WithContext, MappingsTriCaster, TriCasterState, TriCasterStateDiffer } from './triCasterStateDiffer' import { TriCasterCommandWithContext } from './triCasterCommands' import { TriCasterConnection } from './triCasterConnection' +import { Device } from '../../service/device' const DEFAULT_PORT = 5951 export type DeviceOptionsTriCasterInternal = DeviceOptionsTriCaster -export class TriCasterDevice extends DeviceWithState, DeviceOptionsTriCasterInternal> { - private _doOnTime: DoOnTime +export class TriCasterDevice extends Device< + TriCasterOptions, + WithContext, + TriCasterCommandWithContext +> { + readonly actions: { + [id: string]: (id: string, payload?: Record) => Promise + } = {} private _connected = false private _initialized = false @@ -29,15 +35,6 @@ export class TriCasterDevice extends DeviceWithState private _connection?: TriCasterConnection private _stateDiffer?: TriCasterStateDiffer - constructor(deviceId: string, deviceOptions: DeviceOptionsTriCasterInternal, getCurrentTime: () => Promise) { - super(deviceId, deviceOptions, getCurrentTime) - - this._doOnTime = new DoOnTime(() => this.getCurrentTime(), SendMode.BURST, this._deviceOptions) - this._doOnTime.on('error', (e) => this.emit('error', 'TriCasterDevice.doOnTime', e)) - this._doOnTime.on('slowCommand', (msg) => this.emit('slowCommand', this.deviceName + ': ' + msg)) - this._doOnTime.on('slowSentCommand', (info) => this.emit('slowSentCommand', info)) - this._doOnTime.on('slowFulfilledCommand', (info) => this.emit('slowFulfilledCommand', info)) - } async init(options: TriCasterOptions): Promise { this._connection = new TriCasterConnection(options.host, options.port ?? DEFAULT_PORT) this._connection.on('connected', (info, shortcutStateXml) => { @@ -45,16 +42,16 @@ export class TriCasterDevice extends DeviceWithState this._setInitialState(shortcutStateXml) this._setConnected(true) this._initialized = true - this.emit('info', `Connected to TriCaster ${info.productModel}, session: ${info.sessionName}`) + this.context.logger.info(`Connected to TriCaster ${info.productModel}, session: ${info.sessionName}`) }) this._connection.on('disconnected', (reason) => { if (!this._isTerminating) { - this.emit('warning', `TriCaster disconected due to: ${reason}`) + this.context.logger.warning(`TriCaster disconected due to: ${reason}`) } this._setConnected(false) }) this._connection.on('error', (reason) => { - this.emit('error', 'TriCasterConnection', reason) + this.context.logger.error('TriCasterConnection', reason) }) this._connection.connect() return true @@ -64,63 +61,61 @@ export class TriCasterDevice extends DeviceWithState if (!this._stateDiffer) { throw new Error('State Differ not available') } - const time = this.getCurrentTime() - const state = this._stateDiffer.shortcutStateConverter.getTriCasterStateFromShortcutState(shortcutStateXml) - this.setState(state, time) - } - private _connectionChanged(): void { - this.emit('connectionChanged', this.getStatus()) + const state = this._stateDiffer.shortcutStateConverter.getTriCasterStateFromShortcutState(shortcutStateXml) + this.context.resetToState(state).catch((e) => { + this.context.logger.error('Error setting initial TriCaster state', e) + }) } private _setConnected(connected: boolean): void { if (this._connected !== connected) { this._connected = connected - this._connectionChanged() + this.context.connectionChanged(this.getStatus()) } } - /** Called by the Conductor a bit before handleState is called */ - prepareForHandleState(newStateTime: number): void { - // clear any queued commands later than this time: - this._doOnTime.clearQueueNowAndAfter(newStateTime) - this.cleanUpStates(0, newStateTime) - } - - handleState(newState: Timeline.TimelineState, newMappings: Mappings): void { - const triCasterMappings: MappingsTriCaster = this.filterTriCasterMappings(newMappings) - super.onHandleState(newState, newMappings) + /** + * Convert a timeline state into an Tricaster state. + * @param timelineState The state to be converted + */ + convertTimelineStateToDeviceState( + timelineState: Timeline.TimelineState, + mappings: Mappings + ): WithContext { if (!this._initialized || !this._stateDiffer) { // before it's initialized don't do anything - this.emit('warning', 'TriCaster not initialized yet') - return + throw new Error('TriCaster not initialized yet') } - const previousStateTime = Math.max(this.getCurrentTime(), newState.time) - const oldState = - this.getStateBefore(previousStateTime)?.state ?? this._stateDiffer.getDefaultState(triCasterMappings) - - const newTriCasterState = this._stateDiffer.timelineStateConverter.getTriCasterStateFromTimelineState( - newState, - triCasterMappings - ) - - const commandsToAchieveState = this._stateDiffer.getCommandsToAchieveState(newTriCasterState, oldState) + const triCasterMappings: MappingsTriCaster = this.filterTriCasterMappings(mappings) - // clear any queued commands later than this time: - this._doOnTime.clearQueueNowAndAfter(previousStateTime) + return this._stateDiffer.timelineStateConverter.getTriCasterStateFromTimelineState(timelineState, triCasterMappings) + } - // add the new commands to the queue: - this._addToQueue(commandsToAchieveState, newState.time) + /** + * Compares the new timeline-state with the old one, and generates commands to account for the difference + * @param oldAtemState + * @param newAtemState + */ + diffStates( + oldTriCasterState: WithContext | undefined, + newTriCasterState: WithContext, + _mappings: Mappings + ): Array { + if (!this._initialized || !this._stateDiffer) { + // before it's initialized don't do anything + this.context.logger.warning('TriCaster not initialized yet') + return [] + } - // store the new state, for later use: - this.setState(newTriCasterState, newState.time) + return this._stateDiffer.getCommandsToAchieveState(newTriCasterState, oldTriCasterState) } private filterTriCasterMappings(newMappings: Mappings): MappingsTriCaster { return Object.entries>(newMappings).reduce( (accumulator, [layerName, mapping]) => { - if (mapping.device === DeviceType.TRICASTER && mapping.deviceId === this.deviceId) { + if (mapping.device === DeviceType.TRICASTER) { accumulator[layerName] = mapping as Mapping } return accumulator @@ -129,18 +124,12 @@ export class TriCasterDevice extends DeviceWithState ) } - clearFuture(clearAfterTime: number): void { - // Clear any scheduled commands after this time - this._doOnTime.clearQueueAfter(clearAfterTime) - } - async terminate(): Promise { this._isTerminating = true - this._doOnTime.dispose() this._connection?.close() } - getStatus(): DeviceStatus { + getStatus(): Omit { let statusCode = StatusCode.GOOD const messages: Array = [] @@ -152,44 +141,15 @@ export class TriCasterDevice extends DeviceWithState return { statusCode: statusCode, messages: messages, - active: this.isActive, - } - } - - async makeReady(okToDestroyStuff?: boolean): Promise { - if (okToDestroyStuff) { - // do something? } } - get canConnect(): boolean { - return true - } - get connected(): boolean { return this._connected } - get deviceType() { - return DeviceType.TRICASTER - } - - get deviceName(): string { - return 'TriCaster ' + this.deviceId - } - - get queue() { - return this._doOnTime.getQueue() - } - - private _addToQueue(commandsToAchieveState: Array, time: number): void { - _.each(commandsToAchieveState, (cmd: TriCasterCommandWithContext) => { - this._doOnTime.queue(time, undefined, async (cmd: TriCasterCommandWithContext) => this._sendCommand(cmd), cmd) - }) - } - - private _sendCommand = (commandWithContext: TriCasterCommandWithContext): Promise | undefined => { - this.emitDebug(commandWithContext) + async sendCommand(commandWithContext: TriCasterCommandWithContext): Promise { + this.context.logger.debug(commandWithContext) return this._connection?.send(commandWithContext.command) } diff --git a/packages/timeline-state-resolver/src/integrations/tricaster/triCasterCommands.ts b/packages/timeline-state-resolver/src/integrations/tricaster/triCasterCommands.ts index 3aa737fab..d2f9d9c1c 100644 --- a/packages/timeline-state-resolver/src/integrations/tricaster/triCasterCommands.ts +++ b/packages/timeline-state-resolver/src/integrations/tricaster/triCasterCommands.ts @@ -172,8 +172,8 @@ export type TriCasterGenericCommandName = T extends boolean export type TriCasterCommandContext = any export interface TriCasterCommandWithContext { command: TriCasterCommand - context?: TriCasterCommandContext - timelineObjId?: string + context: TriCasterCommandContext + timelineObjId: string temporalPriority: number } diff --git a/packages/timeline-state-resolver/src/integrations/tricaster/triCasterStateDiffer.ts b/packages/timeline-state-resolver/src/integrations/tricaster/triCasterStateDiffer.ts index eb9ed6c2b..35c51c908 100644 --- a/packages/timeline-state-resolver/src/integrations/tricaster/triCasterStateDiffer.ts +++ b/packages/timeline-state-resolver/src/integrations/tricaster/triCasterStateDiffer.ts @@ -271,7 +271,7 @@ export class TriCasterStateDiffer { getCommandsToAchieveState( newState: WithContext, - oldState: WithContext + oldState: WithContext | undefined ): TriCasterCommandWithContext[] { const commands: TriCasterCommandWithContext[] = [] this.recursivelyGenerateCommands(commands, this.commandGenerator, newState, oldState, '') @@ -581,7 +581,8 @@ export function wrapStateInContext(state: T): WithContext { export function wrapInContext(command: TriCasterCommand, entry: StateEntry): TriCasterCommandWithContext { return { command, - timelineObjId: entry.timelineObjId, + context: '', + timelineObjId: entry.timelineObjId ?? '', temporalPriority: entry.temporalPriority ?? DEFAULT_TEMPORAL_PRIORITY, } } diff --git a/packages/timeline-state-resolver/src/service/devices.ts b/packages/timeline-state-resolver/src/service/devices.ts index dc724b0ff..0c0249973 100644 --- a/packages/timeline-state-resolver/src/service/devices.ts +++ b/packages/timeline-state-resolver/src/service/devices.ts @@ -14,6 +14,7 @@ import { PanasonicPtzDevice } from '../integrations/panasonicPTZ' import { LawoDevice } from '../integrations/lawo' import { SofieChefDevice } from '../integrations/sofieChef' import { PharosDevice } from '../integrations/pharos' +import { TriCasterDevice } from '../integrations/tricaster' export interface DeviceEntry { deviceClass: new (context: DeviceContextAPI) => Device @@ -36,6 +37,7 @@ export type ImplementedServiceDeviceTypes = | DeviceType.SHOTOKU | DeviceType.SOFIE_CHEF | DeviceType.TCPSEND + | DeviceType.TRICASTER | DeviceType.QUANTEL // TODO - move all device implementations here and remove the old Device classes @@ -118,6 +120,12 @@ export const DevicesDict: Record = { deviceName: (deviceId: string) => 'TCP' + deviceId, executionMode: () => 'sequential', // todo: should this be configurable? }, + [DeviceType.TRICASTER]: { + deviceClass: TriCasterDevice, + canConnect: true, + deviceName: (deviceId: string) => 'TriCaster ' + deviceId, + executionMode: () => 'salvo', + }, [DeviceType.QUANTEL]: { deviceClass: QuantelDevice, canConnect: true,