From d57812c2b2519635cadb7279803601f06918f91c Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Thu, 20 Jun 2024 09:58:07 +0100 Subject: [PATCH] feat: refactor pharos device SOFIE-2488 (#333) --- .../src/integrations/pharos.ts | 2 +- .../timeline-state-resolver/jest.config.js | 2 +- .../timeline-state-resolver/src/conductor.ts | 14 +- .../pharos/__tests__/pharos.spec.ts | 280 +++++++------ .../src/integrations/pharos/diffStates.ts | 195 +++++++++ .../src/integrations/pharos/index.ts | 396 +++--------------- .../src/service/devices.ts | 8 + scratch/ember-test.js | 92 ++++ 8 files changed, 510 insertions(+), 479 deletions(-) create mode 100644 packages/timeline-state-resolver/src/integrations/pharos/diffStates.ts create mode 100644 scratch/ember-test.js diff --git a/packages/timeline-state-resolver-types/src/integrations/pharos.ts b/packages/timeline-state-resolver-types/src/integrations/pharos.ts index c0e585d681..151ba50bea 100644 --- a/packages/timeline-state-resolver-types/src/integrations/pharos.ts +++ b/packages/timeline-state-resolver-types/src/integrations/pharos.ts @@ -30,6 +30,6 @@ export interface TimelineContentPharosTimeline extends TimelineContentPharos { timeline: number pause?: boolean - rate?: boolean + rate?: number fade?: number } diff --git a/packages/timeline-state-resolver/jest.config.js b/packages/timeline-state-resolver/jest.config.js index 6155c47b8a..7d70639a0f 100644 --- a/packages/timeline-state-resolver/jest.config.js +++ b/packages/timeline-state-resolver/jest.config.js @@ -5,7 +5,7 @@ module.exports = { 'ts-jest', { tsconfig: 'tsconfig.json', - diagnostics: { ignoreCodes: [6133] }, + diagnostics: { ignoreCodes: [6133, 6192] }, }, ], }, diff --git a/packages/timeline-state-resolver/src/conductor.ts b/packages/timeline-state-resolver/src/conductor.ts index e8e135f532..fc289b8564 100644 --- a/packages/timeline-state-resolver/src/conductor.ts +++ b/packages/timeline-state-resolver/src/conductor.ts @@ -34,6 +34,7 @@ import { DeviceOptionsPanasonicPTZ, DeviceOptionsLawo, DeviceOptionsSofieChef, + DeviceOptionsPharos, } from 'timeline-state-resolver-types' import { DoOnTime } from './devices/doOnTime' @@ -44,7 +45,6 @@ import { CommandWithContext } from './devices/device' import { DeviceContainer } from './devices/deviceContainer' import { CasparCGDevice, DeviceOptionsCasparCGInternal } from './integrations/casparCG' -import { PharosDevice, DeviceOptionsPharosInternal } from './integrations/pharos' import { SisyfosMessageDevice, DeviceOptionsSisyfosInternal } from './integrations/sisyfos' import { SingularLiveDevice, DeviceOptionsSingularLiveInternal } from './integrations/singularLive' import { VMixDevice, DeviceOptionsVMixInternal } from './integrations/vmix' @@ -510,15 +510,6 @@ export class Conductor extends EventEmitter { getCurrentTime, threadedClassOptions ) - case DeviceType.PHAROS: - return DeviceContainer.create( - '../../dist/integrations/pharos/index.js', - 'PharosDevice', - deviceId, - deviceOptions, - getCurrentTime, - threadedClassOptions - ) case DeviceType.SISYFOS: return DeviceContainer.create( '../../dist/integrations/sisyfos/index.js', @@ -591,6 +582,7 @@ export class Conductor extends EventEmitter { case DeviceType.OBS: case DeviceType.OSC: case DeviceType.PANASONIC_PTZ: + case DeviceType.PHAROS: case DeviceType.SHOTOKU: case DeviceType.SOFIE_CHEF: case DeviceType.TCPSEND: @@ -1505,7 +1497,7 @@ export type DeviceOptionsAnyInternal = | DeviceOptionsPanasonicPTZ | DeviceOptionsTCPSend | DeviceOptionsHyperdeck - | DeviceOptionsPharosInternal + | DeviceOptionsPharos | DeviceOptionsOBS | DeviceOptionsOSC | DeviceOptionsMultiOSCInternal diff --git a/packages/timeline-state-resolver/src/integrations/pharos/__tests__/pharos.spec.ts b/packages/timeline-state-resolver/src/integrations/pharos/__tests__/pharos.spec.ts index ba724b58a0..4e68d17b60 100644 --- a/packages/timeline-state-resolver/src/integrations/pharos/__tests__/pharos.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/pharos/__tests__/pharos.spec.ts @@ -1,5 +1,3 @@ -import { Conductor } from '../../../conductor' -import { PharosDevice } from '..' import { Mappings, DeviceType, @@ -7,34 +5,108 @@ import { SomeMappingPharos, TimelineContentTypePharos, } from 'timeline-state-resolver-types' -import { MockTime } from '../../../__tests__/mockTime' -import { ThreadedClass } from 'threadedclass' -import { getMockCall } from '../../../__tests__/lib' -import * as WebSocket from '../../../__mocks__/ws' +import { getDeviceContext } from '../../__tests__/testlib' +import { EventEmitter } from 'events' +import type { Pharos, ProjectInfo } from '../connection' +import { makeTimelineObjectResolved } from '../../../__mocks__/objects' + +class MockPharosApi + extends EventEmitter + implements + Pick< + Pharos, + | 'connect' + | 'dispose' + | 'getProjectInfo' + | 'releaseScene' + | 'releaseTimeline' + | 'pauseTimeline' + | 'resumeTimeline' + | 'setTimelineRate' + | 'startScene' + | 'startTimeline' + > +{ + static instances: MockPharosApi[] = [] + constructor() { + super() + + MockPharosApi.instances.push(this) + } + + connected = false + + connect = jest.fn(async () => { + this.connected = true + + setImmediate(() => this.emit('connected')) + }) + dispose = jest.fn(async () => { + this.connected = false + + setImmediate(() => this.emit('disconnected')) + }) + + getProjectInfo = jest.fn(async () => { + return { + author: 'Jest', + filename: 'filename', + name: 'Jest test mock', + unique_id: 'abcde123', + upload_date: '2018-10-22T08:09:02', + } satisfies ProjectInfo + }) + + commandCalls: any[] = [] + releaseScene = jest.fn(async (scene: number, fade?: number) => { + this.commandCalls.push({ type: 'releaseScene', scene, fade }) + }) + releaseTimeline = jest.fn(async (timeline: number, fade?: number) => { + this.commandCalls.push({ type: 'releaseTimeline', timeline, fade }) + }) + pauseTimeline = jest.fn(async (timeline: number) => { + this.commandCalls.push({ type: 'pauseTimeline', timeline }) + }) + resumeTimeline = jest.fn(async (timeline: number) => { + this.commandCalls.push({ type: 'resumeTimeline', timeline }) + }) + setTimelineRate = jest.fn(async (timeline: number, rate: number) => { + this.commandCalls.push({ type: 'setTimelineRate', timeline, rate }) + }) + startScene = jest.fn(async (scene: number, fade?: number) => { + this.commandCalls.push({ type: 'startScene', scene, fade }) + }) + startTimeline = jest.fn(async (timeline: number, rate?: number) => { + this.commandCalls.push({ type: 'startTimeline', timeline, rate }) + }) +} +jest.mock('../connection', () => ({ Pharos: MockPharosApi })) +import { PharosDevice, PharosState } from '..' describe('Pharos', () => { - jest.mock('ws', () => WebSocket) - const mockTime = new MockTime() + jest.mock('ws', () => null) beforeEach(() => { - mockTime.init() - - WebSocket.clearMockInstances() + MockPharosApi.instances = [] + }) - jest.useRealTimers() - setTimeout(() => { - const wsInstances = WebSocket.getMockInstances() - if (wsInstances.length !== 1) throw new Error('WebSocket Mock Instance not created') - WebSocket.getMockInstances()[0].mockSetConnected(true) - }, 200) - jest.useFakeTimers() + afterEach(() => { + // eslint-disable-next-line jest/no-standalone-expect + expect(MockPharosApi.instances).toHaveLength(1) }) + + // Future: this tests should be rewritten to be less monolithic and more granular test('Scene', async () => { - let device: any = undefined - const commandReceiver0: any = jest.fn((...args) => { - // pipe through the command - return device._defaultCommandReceiver(...args) - // return Promise.resolve() + const context = getDeviceContext() + const pharos = new PharosDevice(context) + + await pharos.init({ + host: '127.0.0.1', }) + expect(pharos).toBeTruthy() + + const mockApi = MockPharosApi.instances[0] + expect(mockApi).toBeTruthy() + const myLayerMapping0: Mapping = { device: DeviceType.PHAROS, deviceId: 'myPharos', @@ -44,68 +116,15 @@ describe('Pharos', () => { myLayer0: myLayerMapping0, } - const myConductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) - const errorHandler = jest.fn() - myConductor.on('error', errorHandler) - - const mockReply = jest.fn((_ws: WebSocket, message: string) => { - const data = JSON.parse(message) - if (data.request === 'project') { - return JSON.stringify({ - request: data.request, - author: 'Jest', - filename: 'filename', - name: 'Jest test mock', - unique_id: 'abcde123', - upload_date: '2018-10-22T08:09:02', - }) - } else { - console.log(data) - } - return '' - }) - WebSocket.mockConstructor((ws: WebSocket) => { - // @ts-ignore mock - ws.mockReplyFunction((message) => { - if (message === '') return '' // ping message - - return mockReply(ws, message) - }) - }) - - await myConductor.init() - await myConductor.addDevice('myPharos', { - type: DeviceType.PHAROS, - options: { - host: '127.0.0.1', - }, - commandReceiver: commandReceiver0, - }) - myConductor.setTimelineAndMappings([], myLayerMapping) - - const wsInstances = WebSocket.getMockInstances() - expect(wsInstances).toHaveLength(1) - // let wsInstance = wsInstances[0] - - await mockTime.advanceTimeToTicks(10100) - - const deviceContainer = myConductor.getDevice('myPharos') - device = deviceContainer!.device as ThreadedClass - - expect(mockReply).toHaveBeenCalledTimes(1) - expect(getMockCall(mockReply, 0, 1)).toMatch(/project/) // get project info + const state0: PharosState = {} + const commands0 = pharos.diffStates(undefined, state0, myLayerMapping) + expect(commands0).toHaveLength(0) - // Check that no commands has been scheduled: - expect(await device.queue).toHaveLength(0) - - myConductor.setTimelineAndMappings([ - { + const state1: PharosState = { + myLayer0: makeTimelineObjectResolved({ id: 'scene0', enable: { - start: mockTime.now + 1000, + start: 1000, duration: 5000, }, layer: 'myLayer0', @@ -115,11 +134,21 @@ describe('Pharos', () => { scene: 1, }, - }, - { + }), + } + const commands1 = pharos.diffStates(state0, state1, myLayerMapping) + expect(commands1).toHaveLength(1) + + for (const command of commands1) await pharos.sendCommand(command) + expect(context.commandError).toHaveBeenCalledTimes(0) + expect(mockApi.commandCalls).toEqual([{ type: 'startScene', scene: 1 }]) + mockApi.commandCalls = [] + + const state2: PharosState = { + myLayer0: makeTimelineObjectResolved({ id: 'scene1', enable: { - start: '#scene0.start + 1000', + start: 2000, duration: 5000, }, layer: 'myLayer0', @@ -129,11 +158,24 @@ describe('Pharos', () => { scene: 2, }, - }, - { + }), + } + const commands2 = pharos.diffStates(state1, state2, myLayerMapping) + expect(commands2).toHaveLength(2) + + for (const command of commands2) await pharos.sendCommand(command) + expect(context.commandError).toHaveBeenCalledTimes(0) + expect(mockApi.commandCalls).toEqual([ + { type: 'releaseScene', scene: 1 }, + { type: 'startScene', scene: 2 }, + ]) + mockApi.commandCalls = [] + + const state3: PharosState = { + myLayer0: makeTimelineObjectResolved({ id: 'scene2', enable: { - start: '#scene1.start + 1000', + start: 3000, duration: 1000, }, layer: 'myLayer0', @@ -144,51 +186,25 @@ describe('Pharos', () => { scene: 2, stopped: true, }, - }, + }), + } + const commands3 = pharos.diffStates(state2, state3, myLayerMapping) + expect(commands3).toHaveLength(2) + + for (const command of commands3) await pharos.sendCommand(command) + expect(context.commandError).toHaveBeenCalledTimes(0) + expect(mockApi.commandCalls).toEqual([ + { type: 'releaseScene', scene: 2 }, + { type: 'releaseScene', scene: 2 }, ]) + mockApi.commandCalls = [] + + const state4: PharosState = {} + const commands4 = pharos.diffStates(state3, state4, myLayerMapping) + expect(commands4).toHaveLength(1) - await mockTime.advanceTimeToTicks(10990) - expect(commandReceiver0).toHaveBeenCalledTimes(0) - - mockReply.mockReset() - expect(mockReply).toHaveBeenCalledTimes(0) - - await mockTime.advanceTimeToTicks(11500) - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(getMockCall(commandReceiver0, 0, 1).content.args[0]).toEqual(1) // scene - expect(getMockCall(commandReceiver0, 0, 2)).toMatch(/added/) // context - expect(getMockCall(commandReceiver0, 0, 2)).toMatch(/scene0/) // context - - await mockTime.advanceTimeToTicks(12500) - expect(commandReceiver0).toHaveBeenCalledTimes(3) - expect(getMockCall(commandReceiver0, 1, 1).content.args[0]).toEqual(1) // scene - expect(getMockCall(commandReceiver0, 1, 2)).toMatch(/changed from/) // context - expect(getMockCall(commandReceiver0, 1, 2)).toMatch(/scene0/) // context - - expect(getMockCall(commandReceiver0, 2, 1).content.args[0]).toEqual(2) // scene - expect(getMockCall(commandReceiver0, 2, 2)).toMatch(/changed to/) // context - expect(getMockCall(commandReceiver0, 2, 2)).toMatch(/scene1/) // context - - await mockTime.advanceTimeToTicks(13500) - expect(commandReceiver0).toHaveBeenCalledTimes(5) - expect(getMockCall(commandReceiver0, 3, 1).content.args[0]).toEqual(2) // scene - expect(getMockCall(commandReceiver0, 3, 2)).toMatch(/removed/) // context - expect(getMockCall(commandReceiver0, 3, 2)).toMatch(/scene1/) // context - - expect(getMockCall(commandReceiver0, 4, 1).content.args[0]).toEqual(2) // scene - expect(getMockCall(commandReceiver0, 4, 2)).toMatch(/removed/) // context - expect(getMockCall(commandReceiver0, 4, 2)).toMatch(/scene2/) // context - - await mockTime.advanceTimeToTicks(14500) - expect(commandReceiver0).toHaveBeenCalledTimes(6) - expect(getMockCall(commandReceiver0, 5, 1).content.args[0]).toEqual(2) // scene - expect(getMockCall(commandReceiver0, 5, 2)).toMatch(/added/) // context - expect(getMockCall(commandReceiver0, 5, 2)).toMatch(/scene1/) // context - - await mockTime.advanceTimeToTicks(20000) - expect(commandReceiver0).toHaveBeenCalledTimes(7) - expect(getMockCall(commandReceiver0, 6, 1).content.args[0]).toEqual(2) // scene - expect(getMockCall(commandReceiver0, 6, 2)).toMatch(/removed/) // context - expect(getMockCall(commandReceiver0, 6, 2)).toMatch(/scene1/) // context + for (const command of commands4) await pharos.sendCommand(command) + expect(context.commandError).toHaveBeenCalledTimes(0) + expect(mockApi.commandCalls).toEqual([{ type: 'releaseScene', scene: 2 }]) }) }) diff --git a/packages/timeline-state-resolver/src/integrations/pharos/diffStates.ts b/packages/timeline-state-resolver/src/integrations/pharos/diffStates.ts new file mode 100644 index 0000000000..bab7986a9c --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/pharos/diffStates.ts @@ -0,0 +1,195 @@ +import { + DeviceType, + TSRTimelineContent, + Timeline, + TimelineContentPharosAny, + TimelineContentPharosScene, + TimelineContentPharosTimeline, + TimelineContentTypePharos, + type Mappings, +} from 'timeline-state-resolver-types' +import type { PharosState, PharosCommandWithContext } from '.' + +type TimelineObjAny = Timeline.ResolvedTimelineObjectInstance +type TimelineObjPharos = Timeline.ResolvedTimelineObjectInstance + +const isPharosObject = (obj: TimelineObjAny | undefined): obj is TimelineObjPharos => { + return !!obj && obj.content.deviceType === DeviceType.PHAROS +} + +export function diffStates( + oldPharosState: PharosState | undefined, + newPharosState: PharosState, + _mappings: Mappings +): Array { + const commands: PharosCommandWithContext[] = [] + + const stoppedLayers = new Set() + const stopLayer = (oldLayer: TimelineObjPharos, reason?: string) => { + if (stoppedLayers.has(oldLayer.id)) return // don't send several remove commands for the same object + + if (oldLayer.content.noRelease) return // override: don't stop / release + + stoppedLayers.add(oldLayer.id) + + if (oldLayer.content.type === TimelineContentTypePharos.SCENE) { + if (!reason) reason = 'removed scene' + + const scene = oldLayer.content.scene + const fade = oldLayer.content.fade + + commands.push({ + command: { + fcn: async (pharos) => pharos.releaseScene(scene, fade), + }, + context: `${reason}: ${oldLayer.id} ${scene}`, + timelineObjId: oldLayer.id, + }) + } else if (oldLayer.content.type === TimelineContentTypePharos.TIMELINE) { + if (!reason) reason = 'removed timeline' + + const timeline = oldLayer.content.timeline + const fade = oldLayer.content.fade + + commands.push({ + command: { + fcn: async (pharos) => pharos.releaseTimeline(timeline, fade), + }, + context: `${reason}: ${oldLayer.id} ${timeline}`, + timelineObjId: oldLayer.id, + }) + } + } + + const modifyTimelinePlay = (newLayer: TimelineObjPharos, oldLayer?: TimelineObjPharos) => { + if (newLayer.content.type === TimelineContentTypePharos.TIMELINE) { + if ( + (newLayer.content.pause || false) !== + ((oldLayer?.content as TimelineContentPharosTimeline | undefined)?.pause || false) + ) { + const timeline = newLayer.content.timeline + + if (newLayer.content.pause) { + commands.push({ + command: { + fcn: async (pharos) => pharos.pauseTimeline(timeline), + }, + context: `pause timeline: ${newLayer.id} ${timeline}`, + timelineObjId: newLayer.id, + }) + } else { + commands.push({ + command: { + fcn: async (pharos) => pharos.resumeTimeline(timeline), + }, + context: `resume timeline: ${newLayer.id} ${timeline}`, + timelineObjId: newLayer.id, + }) + } + } + if ( + (newLayer.content.rate || null) !== + ((oldLayer?.content as TimelineContentPharosTimeline | undefined)?.rate || null) + ) { + const timeline = newLayer.content.timeline + const rate = newLayer.content.rate + + commands.push({ + command: { + fcn: async (pharos) => pharos.setTimelineRate(timeline, rate ?? 0), + }, + context: `pause timeline: ${newLayer.id} ${timeline}: ${rate}`, + timelineObjId: newLayer.id, + }) + } + // @todo: support pause / setTimelinePosition + } + } + + const startLayer = (newLayer: TimelineObjPharos, reason?: string) => { + if (newLayer.content.stopped) { + // Item is set to "stopped" + stopLayer(newLayer) + } else if (newLayer.content.type === TimelineContentTypePharos.SCENE) { + if (!reason) reason = 'added scene' + + const scene = newLayer.content.scene + + commands.push({ + command: { + fcn: async (pharos) => pharos.startScene(scene), + }, + context: `${reason}: ${newLayer.id} ${scene}`, + timelineObjId: newLayer.id, + }) + } else if (newLayer.content.type === TimelineContentTypePharos.TIMELINE) { + if (!reason) reason = 'added timeline' + + const timeline = newLayer.content.timeline + + commands.push({ + command: { + fcn: async (pharos) => pharos.startTimeline(timeline), + }, + context: `${reason}: ${newLayer.id} ${timeline}`, + timelineObjId: newLayer.id, + }) + modifyTimelinePlay(newLayer) + } + } + + // Added / Changed things: + for (const [layerKey, newLayer] of Object.entries(newPharosState)) { + const oldPharosObj0 = oldPharosState?.[layerKey] + const oldPharosObj = isPharosObject(oldPharosObj0) ? oldPharosObj0 : undefined + + const pharosObj = isPharosObject(newLayer) ? newLayer : undefined + + if (!pharosObj) { + if (oldPharosObj) { + stopLayer(oldPharosObj) + } + } else if (!oldPharosObj || !isPharosObject(oldPharosObj)) { + // item is new + startLayer(pharosObj) + } + // item is not new, but maybe it has changed: + else if ( + pharosObj.content.type !== oldPharosObj.content.type || // item has changed type! + (pharosObj.content.stopped || false) !== (oldPharosObj.content.stopped || false) // item has stopped / unstopped + ) { + if (!oldPharosObj.content.stopped) { + // If it was stopped before, we don't have to stop it now: + stopLayer(oldPharosObj) + } + startLayer(pharosObj) + } else if (pharosObj.content.type === TimelineContentTypePharos.SCENE) { + if (pharosObj.content.scene !== (oldPharosObj.content as TimelineContentPharosScene).scene) { + // scene has changed + stopLayer(oldPharosObj, 'scene changed from') + startLayer(pharosObj, 'scene changed to') + } + } else if (pharosObj.content.type === TimelineContentTypePharos.TIMELINE) { + if (pharosObj.content.timeline !== (oldPharosObj.content as TimelineContentPharosTimeline).timeline) { + // timeline has changed + stopLayer(oldPharosObj, 'timeline changed from') + startLayer(pharosObj, 'timeline changed to') + } else { + modifyTimelinePlay(pharosObj, oldPharosObj) + } + } + } + + // Removed things + if (oldPharosState) { + for (const [layerKey, oldLayer] of Object.entries(oldPharosState)) { + const newLayer = newPharosState[layerKey] + if (!newLayer && isPharosObject(oldLayer)) { + // removed item + stopLayer(oldLayer) + } + } + } + + return commands +} diff --git a/packages/timeline-state-resolver/src/integrations/pharos/index.ts b/packages/timeline-state-resolver/src/integrations/pharos/index.ts index 3b097c09d2..c1e0250753 100644 --- a/packages/timeline-state-resolver/src/integrations/pharos/index.ts +++ b/packages/timeline-state-resolver/src/integrations/pharos/index.ts @@ -1,73 +1,43 @@ -import * as _ from 'underscore' -import { DeviceWithState, CommandWithContext, DeviceStatus, StatusCode } from './../../devices/device' import { - DeviceType, PharosOptions, - TimelineContentTypePharos, - TimelineContentPharosScene, - TimelineContentPharosTimeline, - DeviceOptionsPharos, Mappings, - TimelineContentPharosAny, TSRTimelineContent, Timeline, ActionExecutionResult, + DeviceStatus, + StatusCode, } from 'timeline-state-resolver-types' +import { Pharos } from './connection' +import { Device, CommandWithContext, DeviceContextAPI } from '../../service/device' +import { diffStates } from './diffStates' -import { DoOnTime, SendMode } from '../../devices/doOnTime' -import { Pharos, ProjectInfo } from './connection' -import { actionNotFoundMessage } from '../../lib' - -export interface DeviceOptionsPharosInternal extends DeviceOptionsPharos { - commandReceiver?: CommandReceiver -} -export type CommandReceiver = ( - time: number, - cmd: Command, - context: CommandContext, - timelineObjId: string -) => Promise -export interface Command { - content: CommandContent - context: CommandContext +export interface PharosCommandWithContext { + command: CommandContent + context: string timelineObjId: string } -type PharosState = Timeline.TimelineState +export type PharosState = Timeline.StateInTime interface CommandContent { - fcn: (...args: any[]) => Promise - args: any[] + fcn: (pharos: Pharos) => Promise } -type CommandContext = string + /** * This is a wrapper for a Pharos-devices, * https://www.pharoscontrols.com/downloads/documentation/application-notes/ */ -export class PharosDevice extends DeviceWithState { - private _doOnTime: DoOnTime +export class PharosDevice extends Device { + readonly actions: { + [id: string]: (id: string, payload?: Record) => Promise + } = {} private _pharos: Pharos - private _pharosProjectInfo?: ProjectInfo - private _commandReceiver: CommandReceiver = this._defaultCommandReceiver.bind(this) - - constructor(deviceId: string, deviceOptions: DeviceOptionsPharosInternal, getCurrentTime: () => Promise) { - super(deviceId, deviceOptions, getCurrentTime) - if (deviceOptions.options) { - if (deviceOptions.commandReceiver) this._commandReceiver = deviceOptions.commandReceiver - } - this._doOnTime = new DoOnTime( - () => { - return this.getCurrentTime() - }, - SendMode.BURST, - this._deviceOptions - ) - this.handleDoOnTime(this._doOnTime, 'Pharos') + constructor(context: DeviceContextAPI) { + super(context) this._pharos = new Pharos() - - this._pharos.on('error', (e) => this.emit('error', 'Pharos', e)) + this._pharos.on('error', (e) => this.context.logger.error('Pharos', e)) this._pharos.on('connected', () => { this._connectionChanged() }) @@ -80,86 +50,38 @@ export class PharosDevice extends DeviceWithState { - return new Promise((resolve, reject) => { - // This is where we would do initialization, like connecting to the devices, etc - this._pharos - .connect(initOptions) - .then(async () => { - return this._pharos.getProjectInfo() - }) - .then((systemInfo) => { - this._pharosProjectInfo = systemInfo - }) - .then(() => resolve(true)) - .catch((e) => reject(e)) - }) - } - /** Called by the Conductor a bit before a .handleState is called */ - prepareForHandleState(newStateTime: number) { - // clear any queued commands later than this time: - this._doOnTime.clearQueueNowAndAfter(newStateTime) - this.cleanUpStates(0, newStateTime) - } - /** - * Handles a new state such that the device will be in that state at a specific point - * in time. - * @param newState - */ - handleState(newState: Timeline.TimelineState, newMappings: Mappings) { - super.onHandleState(newState, newMappings) - // Handle this new state, at the point in time specified - - const previousStateTime = Math.max(this.getCurrentTime(), newState.time) - const oldPharosState: PharosState = ( - this.getStateBefore(previousStateTime) || { state: { time: 0, layers: {}, nextEvents: [] } } - ).state - - const newPharosState = this.convertStateToPharos(newState) - - const commandsToAchieveState: Array = this._diffStates(oldPharosState, newPharosState) - - // clear any queued commands later than this time: - this._doOnTime.clearQueueNowAndAfter(previousStateTime) - // add the new commands to the queue: - this._addToQueue(commandsToAchieveState, newState.time) + // This is where we would do initialization, like connecting to the devices, etc + this._pharos + .connect(initOptions) + .then(() => { + this._pharos + .getProjectInfo() + .then((info) => { + this.context.logger.info(`Current project: ${info.name}`) + }) + .catch((e) => this.context.logger.error('Failed to query project', e)) + }) + .catch((e) => this.context.logger.error('Failed to connect', e)) - // store the new state, for later use: - this.setState(newPharosState, newState.time) - } - clearFuture(clearAfterTime: number) { - // Clear any scheduled commands after this time - this._doOnTime.clearQueueAfter(clearAfterTime) + return true } + async terminate() { - this._doOnTime.dispose() await this._pharos.dispose() } - get canConnect(): boolean { - return true - } + get connected(): boolean { return this._pharos.connected } - convertStateToPharos(state: Timeline.TimelineState): PharosState { - return state - } - get deviceType() { - return DeviceType.PHAROS - } - get deviceName(): string { - return 'Pharos ' + this.deviceId + (this._pharosProjectInfo ? ', ' + this._pharosProjectInfo.name : '') - } - get queue() { - return this._doOnTime.getQueue() - } - async makeReady(_okToDestroyStuff?: boolean): Promise { - return Promise.resolve() - } - async executeAction(actionId: string, _payload?: Record | undefined): Promise { - // No actions defined - return actionNotFoundMessage(actionId as never) + + convertTimelineStateToDeviceState( + timelineState: Timeline.TimelineState, + _mappings: Mappings + ): PharosState { + return timelineState.layers } - getStatus(): DeviceStatus { + + getStatus(): Omit { let statusCode = StatusCode.GOOD const messages: Array = [] @@ -171,235 +93,41 @@ export class PharosDevice extends DeviceWithState, time: number) { - _.each(commandsToAchieveState, (cmd: Command) => { - // add the new commands to the queue: - this._doOnTime.queue( - time, - undefined, - async (cmd: Command) => { - return this._commandReceiver(time, cmd, cmd.context, cmd.timelineObjId) - }, - cmd - ) - }) - } + /** * Compares the new timeline-state with the old one, and generates commands to account for the difference + * @param oldAtemState + * @param newAtemState */ - private _diffStates(oldPharosState: PharosState, newPharosState: PharosState) { - const commands: Array = [] - const stoppedLayers: { [layerKey: string]: true } = {} - - const stopLayer = ( - oldLayer: Timeline.ResolvedTimelineObjectInstance, - reason?: string - ) => { - if (stoppedLayers[oldLayer.id]) return // don't send several remove commands for the same object - - if (oldLayer.content.noRelease) return // override: don't stop / release - - stoppedLayers[oldLayer.id] = true - - if (oldLayer.content.type === TimelineContentTypePharos.SCENE) { - if (!reason) reason = 'removed scene' - commands.push({ - content: { - args: [oldLayer.content.scene, oldLayer.content.fade], - fcn: async (scene, fade) => this._pharos.releaseScene(scene, fade), - }, - context: `${reason}: ${oldLayer.id} ${oldLayer.content.scene}`, - timelineObjId: oldLayer.id, - }) - } else if (oldLayer.content.type === TimelineContentTypePharos.TIMELINE) { - if (!reason) reason = 'removed timeline' - commands.push({ - content: { - args: [oldLayer.content.timeline, oldLayer.content.fade], - fcn: async (timeline, fade) => this._pharos.releaseTimeline(timeline, fade), - }, - context: `${reason}: ${oldLayer.id} ${oldLayer.content.timeline}`, - timelineObjId: oldLayer.id, - }) - } - } - const modifyTimelinePlay = ( - newLayer: Timeline.ResolvedTimelineObjectInstance, - oldLayer?: Timeline.ResolvedTimelineObjectInstance - ) => { - if (newLayer.content.type === TimelineContentTypePharos.TIMELINE) { - if ( - (newLayer.content.pause || false) !== - (oldLayer?.content as TimelineContentPharosTimeline | undefined)?.pause || - false - ) { - if (newLayer.content.pause) { - commands.push({ - content: { - args: [newLayer.content.timeline], - fcn: async (timeline) => this._pharos.pauseTimeline(timeline), - }, - context: `pause timeline: ${newLayer.id} ${newLayer.content.timeline}`, - timelineObjId: newLayer.id, - }) - } else { - commands.push({ - content: { - args: [newLayer.content.timeline], - fcn: async (timeline) => this._pharos.resumeTimeline(timeline), - }, - context: `resume timeline: ${newLayer.id} ${newLayer.content.timeline}`, - timelineObjId: newLayer.id, - }) - } - } - if ( - (newLayer.content.rate || null) !== - ((oldLayer?.content as TimelineContentPharosTimeline | undefined)?.rate || null) - ) { - commands.push({ - content: { - args: [newLayer.content.timeline, newLayer.content.rate], - fcn: async (timeline, rate) => this._pharos.setTimelineRate(timeline, rate), - }, - context: `pause timeline: ${newLayer.id} ${newLayer.content.timeline}: ${newLayer.content.rate}`, - timelineObjId: newLayer.id, - }) - } - // @todo: support pause / setTimelinePosition - } - } - const startLayer = ( - newLayer: Timeline.ResolvedTimelineObjectInstance, - reason?: string - ) => { - if (!newLayer.content.stopped) { - if (newLayer.content.type === TimelineContentTypePharos.SCENE) { - if (!reason) reason = 'added scene' - commands.push({ - content: { - args: [newLayer.content.scene], - fcn: async (scene) => this._pharos.startScene(scene), - }, - context: `${reason}: ${newLayer.id} ${newLayer.content.scene}`, - timelineObjId: newLayer.id, - }) - } else if (newLayer.content.type === TimelineContentTypePharos.TIMELINE) { - if (!reason) reason = 'added timeline' - commands.push({ - content: { - args: [newLayer.content.timeline], - fcn: async (timeline) => this._pharos.startTimeline(timeline), - }, - context: `${reason}: ${newLayer.id} ${newLayer.content.timeline}`, - timelineObjId: newLayer.id, - }) - modifyTimelinePlay(newLayer) - } - } else { - // Item is set to "stopped" - stopLayer(newLayer) - } - } - - const isPharosObject = ( - obj: Timeline.ResolvedTimelineObjectInstance | undefined - ): obj is Timeline.ResolvedTimelineObjectInstance => { - return !!obj && obj.content.deviceType === DeviceType.PHAROS - } - - // Added / Changed things: - _.each(newPharosState.layers, (newLayer, layerKey) => { - const oldPharosObj0 = oldPharosState.layers[layerKey] - const oldPharosObj: Timeline.ResolvedTimelineObjectInstance | undefined = - isPharosObject(oldPharosObj0) ? oldPharosObj0 : undefined - - const pharosObj: Timeline.ResolvedTimelineObjectInstance | undefined = isPharosObject( - newLayer - ) - ? newLayer - : undefined - - if (!pharosObj) { - if (oldPharosObj) { - stopLayer(oldPharosObj) - } - } else if (!oldPharosObj || !isPharosObject(oldPharosObj)) { - // item is new - startLayer(pharosObj) - } else { - // item is not new, but maybe it has changed: - if ( - pharosObj.content.type !== oldPharosObj.content.type || // item has changed type! - (pharosObj.content.stopped || false) !== (oldPharosObj.content.stopped || false) // item has stopped / unstopped - ) { - if (!oldPharosObj.content.stopped) { - // If it was stopped before, we don't have to stop it now: - stopLayer(oldPharosObj) - } - startLayer(pharosObj) - } else { - if (pharosObj.content.type === TimelineContentTypePharos.SCENE) { - if (pharosObj.content.scene !== (oldPharosObj.content as TimelineContentPharosScene).scene) { - // scene has changed - stopLayer(oldPharosObj, 'scene changed from') - startLayer(pharosObj, 'scene changed to') - } - } else if (pharosObj.content.type === TimelineContentTypePharos.TIMELINE) { - if (pharosObj.content.timeline !== (oldPharosObj.content as TimelineContentPharosTimeline).timeline) { - // timeline has changed - stopLayer(oldPharosObj, 'timeline changed from') - startLayer(pharosObj, 'timeline changed to') - } else { - modifyTimelinePlay(pharosObj, oldPharosObj) - } - } - } - } - }) - // Removed things - _.each(oldPharosState.layers, (oldLayer, layerKey) => { - const newLayer = newPharosState.layers[layerKey] - if (!newLayer && isPharosObject(oldLayer)) { - // removed item - stopLayer(oldLayer) - } - }) - - return commands + diffStates( + oldPharosState: PharosState | undefined, + newPharosState: PharosState, + mappings: Mappings + ): Array { + return diffStates(oldPharosState, newPharosState, mappings) } - private async _defaultCommandReceiver( - _time: number, - cmd: Command, - context: CommandContext, - timelineObjId: string - ): Promise { - // emit the command to debug: + + async sendCommand({ command, context, timelineObjId }: PharosCommandWithContext): Promise { const cwc: CommandWithContext = { context, - command: { - // commandName: cmd.content.args, - args: cmd.content.args, - // content: cmd.content - }, + command, timelineObjId, } - this.emitDebug(cwc) + this.context.logger.debug(cwc) + + // Skip attempting send if not connected + if (!this.connected) return - // execute the command here try { - await cmd.content.fcn(...cmd.content.args) - } catch (e) { - this.emit('commandError', e as Error, cwc) + await command.fcn(this._pharos) + } catch (error: any) { + this.context.commandError(error, cwc) } } + private _connectionChanged() { - this.emit('connectionChanged', this.getStatus()) + this.context.connectionChanged(this.getStatus()) } } diff --git a/packages/timeline-state-resolver/src/service/devices.ts b/packages/timeline-state-resolver/src/service/devices.ts index cce1a778e6..dc724b0ffa 100644 --- a/packages/timeline-state-resolver/src/service/devices.ts +++ b/packages/timeline-state-resolver/src/service/devices.ts @@ -13,6 +13,7 @@ import { OBSDevice } from '../integrations/obs' import { PanasonicPtzDevice } from '../integrations/panasonicPTZ' import { LawoDevice } from '../integrations/lawo' import { SofieChefDevice } from '../integrations/sofieChef' +import { PharosDevice } from '../integrations/pharos' export interface DeviceEntry { deviceClass: new (context: DeviceContextAPI) => Device @@ -31,6 +32,7 @@ export type ImplementedServiceDeviceTypes = | DeviceType.OBS | DeviceType.OSC | DeviceType.PANASONIC_PTZ + | DeviceType.PHAROS | DeviceType.SHOTOKU | DeviceType.SOFIE_CHEF | DeviceType.TCPSEND @@ -92,6 +94,12 @@ export const DevicesDict: Record = { deviceName: (deviceId: string) => 'Panasonic PTZ ' + deviceId, executionMode: () => 'salvo', }, + [DeviceType.PHAROS]: { + deviceClass: PharosDevice, + canConnect: true, + deviceName: (deviceId: string) => 'Pharos ' + deviceId, + executionMode: () => 'salvo', + }, [DeviceType.SOFIE_CHEF]: { deviceClass: SofieChefDevice, canConnect: true, diff --git a/scratch/ember-test.js b/scratch/ember-test.js new file mode 100644 index 0000000000..20db82b416 --- /dev/null +++ b/scratch/ember-test.js @@ -0,0 +1,92 @@ +const { + Conductor, + DeviceType, + TimelineContentTypeCasparCg, + MappingSisyfosType, + MappingLawoType, + LawoDeviceMode, + TimelineContentTypeLawo, +} = require('../packages/timeline-state-resolver/dist') + +;(async function () { + const cond = new Conductor({ multiThreadedResolver: true, optimizeForProduction: true }) + cond.on('debug', console.log) + cond.on('warn', console.log) + cond.on('info', console.log) + cond.on('error', console.log) + + const myLayerMapping0 = { + device: DeviceType.LAWO, + deviceId: 'lawo0', + mappingType: MappingLawoType.FULL_PATH, + identifier: 'Ruby.Sums.MAIN.DSP.Delay.On', + } + const myLayerMapping1 = { + device: DeviceType.LAWO, + deviceId: 'lawo0', + mappingType: MappingLawoType.FULL_PATH, + identifier: 'Ruby.Sums.MAIN.DSP.Delay.002', + } + const mappings = { + myLayer0: myLayerMapping0, + myLayer1: myLayerMapping1, + } + + const dev = await cond.addDevice('lawo0', { + type: DeviceType.LAWO, + options: { + host: '127.0.0.1', + port: 9002, + deviceMode: LawoDeviceMode.Ruby, + }, + isMultiThreaded: true, + }) + + await cond.init() + console.log('added') + + dev.device.on('debug', console.log) + dev.device.on('info', console.log) + dev.device.on('warning', console.log) + dev.device.on('error', console.log) + + dev.device.on('connectionChanged', console.log) + + cond.setTimelineAndMappings( + [ + { + id: 'audio0', + enable: { + start: Date.now(), + }, + layer: 'myLayer0', + content: { + deviceType: DeviceType.LAWO, + type: TimelineContentTypeLawo.EMBER_PROPERTY, + + value: false, + }, + keyframes: [], + isLookahead: true, + }, + { + id: 'audio1', + enable: { + start: Date.now(), + }, + layer: 'myLayer1', + content: { + deviceType: DeviceType.LAWO, + type: TimelineContentTypeLawo.EMBER_PROPERTY, + + value: 0, + }, + keyframes: [], + isLookahead: true, + }, + ], + mappings + ) + + console.log('set tl') +})()