From b97a9a888f47ee2fbeb4f2c6cb81d7578dd8347f Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 29 May 2024 15:22:26 +0100 Subject: [PATCH] feat: refactor chef device SOFIE-2494 --- .../timeline-state-resolver/src/conductor.ts | 14 +- .../sofieChef/__tests__/diffStates.spec.ts | 99 ++++++ .../sofieChef/__tests__/sofieChef.spec.ts | 191 +---------- .../src/integrations/sofieChef/diffStates.ts | 64 ++++ .../src/integrations/sofieChef/index.ts | 307 ++++-------------- .../integrations/sofieChef/stateBuilder.ts | 32 ++ .../src/service/devices.ts | 8 + 7 files changed, 278 insertions(+), 437 deletions(-) create mode 100644 packages/timeline-state-resolver/src/integrations/sofieChef/__tests__/diffStates.spec.ts create mode 100644 packages/timeline-state-resolver/src/integrations/sofieChef/diffStates.ts create mode 100644 packages/timeline-state-resolver/src/integrations/sofieChef/stateBuilder.ts diff --git a/packages/timeline-state-resolver/src/conductor.ts b/packages/timeline-state-resolver/src/conductor.ts index 059c83ac2..e8e135f53 100644 --- a/packages/timeline-state-resolver/src/conductor.ts +++ b/packages/timeline-state-resolver/src/conductor.ts @@ -33,6 +33,7 @@ import { DeviceOptionsHyperdeck, DeviceOptionsPanasonicPTZ, DeviceOptionsLawo, + DeviceOptionsSofieChef, } from 'timeline-state-resolver-types' import { DoOnTime } from './devices/doOnTime' @@ -48,7 +49,6 @@ import { SisyfosMessageDevice, DeviceOptionsSisyfosInternal } from './integratio import { SingularLiveDevice, DeviceOptionsSingularLiveInternal } from './integrations/singularLive' import { VMixDevice, DeviceOptionsVMixInternal } from './integrations/vmix' import { VizMSEDevice, DeviceOptionsVizMSEInternal } from './integrations/vizMSE' -import { DeviceOptionsSofieChefInternal, SofieChefDevice } from './integrations/sofieChef' import { TelemetricsDevice } from './integrations/telemetrics' import { TriCasterDevice, DeviceOptionsTriCasterInternal } from './integrations/tricaster' import { DeviceOptionsMultiOSCInternal, MultiOSCMessageDevice } from './integrations/multiOsc' @@ -564,15 +564,6 @@ export class Conductor extends EventEmitter { getCurrentTime, threadedClassOptions ) - case DeviceType.SOFIE_CHEF: - return DeviceContainer.create( - '../../dist/integrations/sofieChef/index.js', - 'SofieChefDevice', - deviceId, - deviceOptions, - getCurrentTime, - threadedClassOptions - ) case DeviceType.TRICASTER: return DeviceContainer.create( '../../dist/integrations/tricaster/index.js', @@ -601,6 +592,7 @@ export class Conductor extends EventEmitter { case DeviceType.OSC: case DeviceType.PANASONIC_PTZ: case DeviceType.SHOTOKU: + case DeviceType.SOFIE_CHEF: case DeviceType.TCPSEND: case DeviceType.QUANTEL: { ensureIsImplementedAsService(deviceOptions.type) @@ -1518,7 +1510,7 @@ export type DeviceOptionsAnyInternal = | DeviceOptionsOSC | DeviceOptionsMultiOSCInternal | DeviceOptionsSisyfosInternal - | DeviceOptionsSofieChefInternal + | DeviceOptionsSofieChef | DeviceOptionsQuantel | DeviceOptionsSingularLiveInternal | DeviceOptionsVMixInternal diff --git a/packages/timeline-state-resolver/src/integrations/sofieChef/__tests__/diffStates.spec.ts b/packages/timeline-state-resolver/src/integrations/sofieChef/__tests__/diffStates.spec.ts new file mode 100644 index 000000000..03e1ef943 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/sofieChef/__tests__/diffStates.spec.ts @@ -0,0 +1,99 @@ +import { diffStates } from '../diffStates' +import { SofieChefCommandWithContext, SofieChefState } from '..' +import { ReceiveWSMessageType } from '../api' + +describe('Diff States', () => { + test('Simple diff against undefined state', async () => { + const state1: SofieChefState = { + windows: { + test: { + url: 'http://test.com', + urlTimelineObjId: 'abc', + }, + }, + } + + const commands = diffStates(undefined, state1, {}) + + expect(commands).toHaveLength(1) + expect(commands[0]).toEqual({ + command: { + msgId: 0, + type: ReceiveWSMessageType.PLAYURL, + url: 'http://test.com', + windowId: 'test', + }, + context: 'added', + timelineObjId: 'abc', + } satisfies SofieChefCommandWithContext) + }) + + test('Simple diff against another state', async () => { + const state1: SofieChefState = { + windows: { + test: { + url: 'http://test.com', + urlTimelineObjId: 'abc', + }, + two: { + url: 'http://two.com', + urlTimelineObjId: 'def', + }, + three: { + url: 'http://three.com', + urlTimelineObjId: 'ghi', + }, + }, + } + + const state2: SofieChefState = { + windows: { + two: { + url: 'http://two.com/2', + urlTimelineObjId: '012', + }, + another: { + url: 'http://another.com', + urlTimelineObjId: '345', + }, + three: { + url: 'http://three.com', + urlTimelineObjId: 'ghi', + }, + }, + } + + const commands = diffStates(state1, state2, {}) + + expect(commands).toHaveLength(3) + expect(commands[0]).toEqual({ + command: { + msgId: 0, + type: ReceiveWSMessageType.PLAYURL, + url: 'http://two.com/2', + windowId: 'two', + }, + context: 'changed', + timelineObjId: '012', + } satisfies SofieChefCommandWithContext) + expect(commands[1]).toEqual({ + command: { + msgId: 0, + type: ReceiveWSMessageType.PLAYURL, + url: 'http://another.com', + windowId: 'another', + }, + context: 'added', + timelineObjId: '345', + } satisfies SofieChefCommandWithContext) + expect(commands[2]).toEqual({ + command: { + msgId: 0, + type: ReceiveWSMessageType.STOP, + windowId: 'test', + }, + context: 'removed', + timelineObjId: 'abc', + } satisfies SofieChefCommandWithContext) + }) +}) diff --git a/packages/timeline-state-resolver/src/integrations/sofieChef/__tests__/sofieChef.spec.ts b/packages/timeline-state-resolver/src/integrations/sofieChef/__tests__/sofieChef.spec.ts index ca8209158..8d9b49814 100644 --- a/packages/timeline-state-resolver/src/integrations/sofieChef/__tests__/sofieChef.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/sofieChef/__tests__/sofieChef.spec.ts @@ -1,20 +1,10 @@ -import { Conductor } from '../../../conductor' import { SofieChefDevice } from '..' -import { - Mappings, - DeviceType, - Mapping, - SomeMappingSofieChef, - TimelineContentTypeSofieChef, - StatusCode, - MappingSofieChefType, -} from 'timeline-state-resolver-types' +import { StatusCode } 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 { literal } from '../../../lib' import { SendWSMessageAny, SendWSMessageType, StatusCode as ChefStatusCode } from '../api' +import { getDeviceContext } from '../../__tests__/testlib' describe('SofieChef', () => { jest.mock('ws', () => WebSocket) @@ -26,31 +16,6 @@ describe('SofieChef', () => { }) test('Status & reconnection', async () => { - let device: any = undefined - const commandReceiver0: any = jest.fn((...args) => { - // pipe through the command - return device._defaultCommandReceiver(...args) - // return Promise.resolve() - }) - const myLayerMapping0: Mapping = { - device: DeviceType.SOFIE_CHEF, - deviceId: 'chef0', - options: { - mappingType: MappingSofieChefType.Window, - windowId: 'window0', - }, - } - const myLayerMapping: Mappings = { - myLayer0: myLayerMapping0, - } - - const myConductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) - const errorHandler = jest.fn() - myConductor.on('error', errorHandler) - WebSocket.mockConstructor((ws: WebSocket) => { // @ts-ignore mock // ws.mockReplyFunction((message) => { @@ -85,32 +50,18 @@ describe('SofieChef', () => { }) }) - await myConductor.init() - await myConductor.addDevice('chef0', { - type: DeviceType.SOFIE_CHEF, - options: { - address: 'ws://127.0.0.1', - }, - commandReceiver: commandReceiver0, + const device = new SofieChefDevice(getDeviceContext()) + await device.init({ + address: 'ws://127.0.0.1', }) - myConductor.setTimelineAndMappings([], myLayerMapping) const wsInstances = WebSocket.getMockInstances() expect(wsInstances).toHaveLength(1) const wsInstance = wsInstances[0] - await mockTime.advanceTimeToTicks(10100) - - const deviceContainer = myConductor.getDevice('chef0') - device = deviceContainer!.device as ThreadedClass - const chefDevice = deviceContainer!.device as ThreadedClass - - // Check that no commands has been scheduled: - expect(await device.queue).toHaveLength(0) - await mockTime.advanceTimeTicks(100) - expect(await chefDevice.getStatus()).toMatchObject({ + expect(device.getStatus()).toMatchObject({ statusCode: StatusCode.GOOD, }) @@ -137,7 +88,7 @@ describe('SofieChef', () => { await mockTime.advanceTimeTicks(100) - expect(await chefDevice.getStatus()).toMatchObject({ + expect(device.getStatus()).toMatchObject({ statusCode: StatusCode.BAD, messages: ['Window 5: whoopsie'], }) @@ -147,7 +98,7 @@ describe('SofieChef', () => { await mockTime.advanceTimeTicks(100) - expect(await chefDevice.getStatus()).toMatchObject({ + expect(device.getStatus()).toMatchObject({ statusCode: StatusCode.BAD, messages: ['Not connected'], }) @@ -157,133 +108,9 @@ describe('SofieChef', () => { await mockTime.advanceTimeTicks(5000) - expect(await chefDevice.getStatus()).toMatchObject({ + expect(device.getStatus()).toMatchObject({ statusCode: StatusCode.GOOD, messages: [], }) }) - test('Play & stop URL', async () => { - let device: any = undefined - const commandReceiver0: any = jest.fn((...args) => { - // pipe through the command - return device._defaultCommandReceiver(...args) - // return Promise.resolve() - }) - const myLayerMapping0: Mapping = { - device: DeviceType.SOFIE_CHEF, - deviceId: 'chef0', - options: { - mappingType: MappingSofieChefType.Window, - windowId: 'window0', - }, - } - const myLayerMapping: Mappings = { - myLayer0: myLayerMapping0, - } - - const myConductor = new Conductor({ - multiThreadedResolver: false, - getCurrentTime: mockTime.getCurrentTime, - }) - const errorHandler = jest.fn() - myConductor.on('error', errorHandler) - - WebSocket.mockConstructor((ws: WebSocket) => { - setImmediate(() => { - ws.mockSetConnected(true) - }) - }) - - await myConductor.init() - await myConductor.addDevice('chef0', { - type: DeviceType.SOFIE_CHEF, - options: { - address: 'ws://127.0.0.1', - }, - commandReceiver: commandReceiver0, - }) - myConductor.setTimelineAndMappings([], myLayerMapping) - - const wsInstances = WebSocket.getMockInstances() - expect(wsInstances).toHaveLength(1) - - await mockTime.advanceTimeToTicks(10100) - - const deviceContainer = myConductor.getDevice('chef0') - device = deviceContainer!.device as ThreadedClass - - // Check that no commands has been scheduled: - expect(await device.queue).toHaveLength(0) - - myConductor.setTimelineAndMappings( - [ - { - id: 'url0', - enable: { - start: 11000, - end: 20000, - }, - layer: 'myLayer0', - content: { - deviceType: DeviceType.SOFIE_CHEF, - type: TimelineContentTypeSofieChef.URL, - url: 'http://google.com', - }, - keyframes: [ - { - id: 'kf0', - enable: { - start: 4000, // 15000 - }, - content: { - url: 'http://yahoo.com', - }, - }, - ], - }, - ], - myLayerMapping - ) - - await mockTime.advanceTimeToTicks(10990) - expect(commandReceiver0).toHaveBeenCalledTimes(0) - - await mockTime.advanceTimeToTicks(11100) - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(getMockCall(commandReceiver0, 0, 1)).toMatchObject({ - content: { - type: 'playurl', - url: 'http://google.com', - windowId: 'window0', - }, - context: 'added', - timelineObjId: 'url0', - }) - - commandReceiver0.mockReset() - - await mockTime.advanceTimeToTicks(18500) - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(getMockCall(commandReceiver0, 0, 1)).toMatchObject({ - content: { - type: 'playurl', - url: 'http://yahoo.com', - windowId: 'window0', - }, - context: 'changed', - timelineObjId: 'url0', - }) - - commandReceiver0.mockReset() - await mockTime.advanceTimeToTicks(20100) - expect(commandReceiver0).toHaveBeenCalledTimes(1) - expect(getMockCall(commandReceiver0, 0, 1)).toMatchObject({ - content: { - type: 'stop', - windowId: 'window0', - }, - context: 'removed', - timelineObjId: 'url0', - }) - }) }) diff --git a/packages/timeline-state-resolver/src/integrations/sofieChef/diffStates.ts b/packages/timeline-state-resolver/src/integrations/sofieChef/diffStates.ts new file mode 100644 index 000000000..ef481b145 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/sofieChef/diffStates.ts @@ -0,0 +1,64 @@ +import type { Mappings } from 'timeline-state-resolver-types' +import type { SofieChefState, SofieChefCommandWithContext } from '.' +import { ReceiveWSMessageType } from './api' + +export function diffStates( + oldSofieChefState: SofieChefState | undefined, + newSofieChefState: SofieChefState, + _mappings: Mappings +): Array { + const commands: SofieChefCommandWithContext[] = [] + + // Added / Changed things: + for (const [windowId, window] of Object.entries(newSofieChefState.windows)) { + const oldWindow = oldSofieChefState?.windows?.[windowId] + if (!oldWindow) { + // Added + commands.push({ + context: 'added', + timelineObjId: window.urlTimelineObjId, + command: { + msgId: 0, // set later + type: ReceiveWSMessageType.PLAYURL, + windowId: windowId, + url: window.url, + }, + }) + } else { + // item is not new, but maybe it has changed: + if (oldWindow.url !== window.url) { + commands.push({ + context: 'changed', + timelineObjId: window.urlTimelineObjId, + command: { + msgId: 0, // set later + type: ReceiveWSMessageType.PLAYURL, + windowId: windowId, + url: window.url, + }, + }) + } + } + } + + // Removed things + if (oldSofieChefState) { + for (const [windowId, oldWindow] of Object.entries(oldSofieChefState.windows)) { + const newWindow = newSofieChefState.windows[windowId] + if (!newWindow) { + // Removed + commands.push({ + context: 'removed', + timelineObjId: oldWindow.urlTimelineObjId, + command: { + msgId: 0, // set later + type: ReceiveWSMessageType.STOP, + windowId: windowId, + }, + }) + } + } + } + + return commands +} diff --git a/packages/timeline-state-resolver/src/integrations/sofieChef/index.ts b/packages/timeline-state-resolver/src/integrations/sofieChef/index.ts index 2c0019d9b..a40aada51 100644 --- a/packages/timeline-state-resolver/src/integrations/sofieChef/index.ts +++ b/packages/timeline-state-resolver/src/integrations/sofieChef/index.ts @@ -1,20 +1,15 @@ import * as _ from 'underscore' -import { DeviceWithState, CommandWithContext, DeviceStatus, StatusCode } from '../../devices/device' import { - DeviceType, SofieChefOptions, - DeviceOptionsSofieChef, Mappings, - SomeMappingSofieChef, TSRTimelineContent, Timeline, ActionExecutionResultCode, SofieChefActions, - SofieChefActionExecutionPayload, - SofieChefActionExecutionResult, + ActionExecutionResult, + StatusCode, + DeviceStatus, } from 'timeline-state-resolver-types' - -import { DoOnTime, SendMode } from '../../devices/doOnTime' import * as WebSocket from 'ws' import { ReceiveWSMessageAny, @@ -26,21 +21,14 @@ import { StatusObject, ReceiveWSMessageResponse, } from './api' -import { actionNotFoundMessage, t } from '../../lib' - -export interface DeviceOptionsSofieChefInternal extends DeviceOptionsSofieChef { - commandReceiver?: CommandReceiver -} -export type CommandReceiver = ( - time: number, - cmd: Command, - context: CommandContext, - timelineObjId: string -) => Promise - -export interface Command { - content: CommandContent - context: CommandContext +import { t } from '../../lib' +import { CommandWithContext, Device } from '../../service/device' +import { diffStates } from './diffStates' +import { buildSofieChefState } from './stateBuilder' + +export interface SofieChefCommandWithContext { + command: ReceiveWSMessageAny + context: string timelineObjId: string } export interface SofieChefState { @@ -56,15 +44,33 @@ export interface SofieChefState { const COMMAND_TIMEOUT_TIME = 5000 const RECONNECT_WAIT_TIME = 5000 -type CommandContent = ReceiveWSMessageAny -type CommandContext = string /** * This is a wrapper for a SofieChef-devices, * https://github.com/nrkno/sofie-chef */ -export class SofieChefDevice extends DeviceWithState { - private _doOnTime: DoOnTime +export class SofieChefDevice extends Device { + readonly actions: { + [id in SofieChefActions]: (id: string, payload?: Record) => Promise + } = { + [SofieChefActions.RestartAllWindows]: async () => + this.restartAllWindows() + .then(() => ({ + result: ActionExecutionResultCode.Ok, + })) + .catch(() => ({ result: ActionExecutionResultCode.Error })), + [SofieChefActions.RestartWindow]: async (_id, payload) => { + if (!payload?.windowId) { + return { result: ActionExecutionResultCode.Error, response: t('Missing window id') } + } + return this.restartWindow(payload.windowId) + .then(() => ({ + result: ActionExecutionResultCode.Ok, + })) + .catch(() => ({ result: ActionExecutionResultCode.Error })) + }, + } + // private _doOnTime: DoOnTime private _ws?: WebSocket private _connected = false private _status: SendWSMessageStatus['status'] = { @@ -74,34 +80,15 @@ export class SofieChefDevice extends DeviceWithState 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, 'SofieChef') - } /** * Initiates the connection with SofieChed through a websocket connection. */ async init(initOptions: SofieChefOptions): Promise { // This is where we would do initialization, like connecting to the devices, etc - this.initOptions = initOptions - await this._setupWSConnection() return true } @@ -117,10 +104,9 @@ export class SofieChefDevice extends DeviceWithState { reject(new Error(`Error when connecting: ${e}`)) - this.emit('error', 'SofieChef', e) + this.context.logger.error('SofieChef', e) }) this._ws.on('open', () => { this._updateConnected(true) @@ -140,17 +126,15 @@ export class SofieChefDevice extends DeviceWithState { delete this.reconnectTimeout - this._setupWSConnection() .then(async () => { // is connected, yay! - // Resync state: await this.resyncState() }) @@ -165,7 +149,6 @@ export class SofieChefDevice extends DeviceWithState, 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 oldSofieChefState: SofieChefState = (this.getStateBefore(previousStateTime) || { state: { windows: {} } }) - .state - - const newSofieChefState = this.convertStateToSofieChef(newState, newMappings) - - const commandsToAchieveState: Array = this._diffStates(oldSofieChefState, newSofieChefState) - - // clear any queued commands later than this time: - this._doOnTime.clearQueueNowAndAfter(previousStateTime) - // add the new commands to the queue: - this._addToQueue(commandsToAchieveState, newState.time) - - // store the new state, for later use: - this.setState(newSofieChefState, newState.time) - } - clearFuture(clearAfterTime: number) { - // Clear any scheduled commands after this time - this._doOnTime.clearQueueAfter(clearAfterTime) - } async terminate() { - this._doOnTime.dispose() - this._ws?.terminate() this._ws?.removeAllListeners() } - get canConnect(): boolean { - return true - } + get connected(): boolean { return this._connected } - convertStateToSofieChef( - state: Timeline.TimelineState, - mappings: Mappings - ): SofieChefState { - const sofieChefState: SofieChefState = { - windows: {}, - } - for (const [layer, layerState] of Object.entries>( - state.layers - )) { - const mapping = mappings[layer] - const content = layerState.content - if (mapping && content.deviceType === DeviceType.SOFIE_CHEF) { - sofieChefState.windows[mapping.options.windowId] = { - url: content.url, - urlTimelineObjId: layerState.id, - } - } - } - return sofieChefState - } - get deviceType() { - return DeviceType.SOFIE_CHEF - } - get deviceName(): string { - return 'SofieChef ' + this.deviceId - } - get queue() { - return this._doOnTime.getQueue() - } - async makeReady(_okToDestroyStuff?: boolean): Promise { - return Promise.resolve() + convertTimelineStateToDeviceState( + timelineState: Timeline.TimelineState, + mappings: Mappings + ): SofieChefState { + return buildSofieChefState(timelineState, mappings) } + /** Restart (reload) all windows */ private async restartAllWindows() { return this._sendMessage({ @@ -279,34 +193,10 @@ export class SofieChefDevice extends DeviceWithState( - actionId: A, - payload: SofieChefActionExecutionPayload - ): Promise> { - switch (actionId) { - case SofieChefActions.RestartAllWindows: - return this.restartAllWindows() - .then(() => ({ - result: ActionExecutionResultCode.Ok, - })) - .catch(() => ({ result: ActionExecutionResultCode.Error })) - case SofieChefActions.RestartWindow: - if (!payload?.windowId) { - return { result: ActionExecutionResultCode.Error, response: t('Missing window id') } - } - return this.restartWindow(payload.windowId) - .then(() => ({ - result: ActionExecutionResultCode.Ok, - })) - .catch(() => ({ result: ActionExecutionResultCode.Error })) - default: - return actionNotFoundMessage(actionId) - } - } - getStatus(): DeviceStatus { + + getStatus(): Omit { let statusCode = StatusCode.GOOD const messages: string[] = [] - if (!this.connected) { statusCode = StatusCode.BAD messages.push('Not connected') @@ -322,11 +212,9 @@ export class SofieChefDevice extends DeviceWithState, time: number) { - for (const cmd of commandsToAchieveState) { - // 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 */ - private _diffStates(oldSofieChefState: SofieChefState, newSofieChefState: SofieChefState) { - const commands: Command[] = [] - - // Added / Changed things: - for (const [windowId, window] of Object.entries(newSofieChefState.windows)) { - const oldWindow = oldSofieChefState.windows[windowId] - - if (!oldWindow) { - // Added - commands.push({ - context: 'added', - timelineObjId: window.urlTimelineObjId, - content: { - msgId: 0, // set later - type: ReceiveWSMessageType.PLAYURL, - windowId: windowId, - url: window.url, - }, - }) - } else { - // item is not new, but maybe it has changed: - if (oldWindow.url !== window.url) { - commands.push({ - context: 'changed', - timelineObjId: window.urlTimelineObjId, - content: { - msgId: 0, // set later - type: ReceiveWSMessageType.PLAYURL, - windowId: windowId, - url: window.url, - }, - }) - } - } - } - // Removed things - for (const [windowId, oldWindow] of Object.entries(oldSofieChefState.windows)) { - const newWindow = newSofieChefState.windows[windowId] - - if (!newWindow) { - // Removed - - commands.push({ - context: 'removed', - timelineObjId: oldWindow.urlTimelineObjId, - content: { - msgId: 0, // set later - type: ReceiveWSMessageType.STOP, - windowId: windowId, - }, - }) - } - } - - return commands + public diffStates( + oldSofieChefState: SofieChefState | undefined, + newSofieChefState: SofieChefState, + mappings: Mappings + ): Array { + return diffStates(oldSofieChefState, newSofieChefState, mappings) } - private async _defaultCommandReceiver( - _time: number, - cmd: Command, - context: CommandContext, - timelineObjId: string - ): Promise { + + public async sendCommand({ command, context, timelineObjId }: SofieChefCommandWithContext): Promise { // emit the command to debug: const cwc: CommandWithContext = { context, - command: cmd.content, + command, timelineObjId, } - this.emitDebug(cwc) + this.context.logger.debug(cwc) // execute the command here try { - await this._sendMessage(cmd.content) + await this._sendMessage(command) } catch (e) { - this.emit('commandError', e as Error, cwc) + this.context.commandError(e as Error, cwc) } } + private _updateConnected(connected: boolean) { if (this._connected !== connected) { this._connected = connected - this.emit('connectionChanged', this.getStatus()) + this.context.connectionChanged(this.getStatus()) } } private _updateStatus(status: SendWSMessageStatus['status']) { if (!_.isEqual(this._status, status)) { this._status = status - this.emit('connectionChanged', this.getStatus()) + this.context.connectionChanged(this.getStatus()) } } private _handleReceivedMessage(data: WebSocket.Data) { @@ -471,29 +292,27 @@ export class SofieChefDevice extends DeviceWithState void reject: (error: any) => void } } = {} - private async _sendMessage(msg: M): Promise> { return new Promise((resolve, reject) => { msg.msgId = this.msgId++ if (this.initOptions?.apiKey) { msg.apiKey = this.initOptions?.apiKey } - this.waitingForReplies[msg.msgId + ''] = { resolve, reject, } this._ws?.send(JSON.stringify(msg)) - setTimeout(() => { reject(new Error(`Command timed out`)) }, COMMAND_TIMEOUT_TIME) diff --git a/packages/timeline-state-resolver/src/integrations/sofieChef/stateBuilder.ts b/packages/timeline-state-resolver/src/integrations/sofieChef/stateBuilder.ts new file mode 100644 index 000000000..3119a5915 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/sofieChef/stateBuilder.ts @@ -0,0 +1,32 @@ +import { + Timeline, + TSRTimelineContent, + Mappings, + Mapping, + SomeMappingSofieChef, + DeviceType, +} from 'timeline-state-resolver-types' +import type { SofieChefState } from '.' + +export function buildSofieChefState( + timelineState: Timeline.TimelineState, + mappings: Mappings +): SofieChefState { + const sofieChefState: SofieChefState = { + windows: {}, + } + for (const [layer, layerState] of Object.entries>( + timelineState.layers + )) { + const mapping = mappings[layer] as Mapping | undefined + const content = layerState.content + + if (mapping && content.deviceType === DeviceType.SOFIE_CHEF) { + sofieChefState.windows[mapping.options.windowId] = { + url: content.url, + urlTimelineObjId: layerState.id, + } + } + } + return sofieChefState +} diff --git a/packages/timeline-state-resolver/src/service/devices.ts b/packages/timeline-state-resolver/src/service/devices.ts index 8a5770fa5..043ede85b 100644 --- a/packages/timeline-state-resolver/src/service/devices.ts +++ b/packages/timeline-state-resolver/src/service/devices.ts @@ -12,6 +12,7 @@ import { HyperdeckDevice } from '../integrations/hyperdeck' import { OBSDevice } from '../integrations/obs' import { PanasonicPtzDevice } from '../integrations/panasonicPTZ' import { LawoDevice } from '../integrations/lawo' +import { SofieChefDevice } from '../integrations/sofieChef' export interface DeviceEntry { deviceClass: new (context: DeviceContextAPI) => Device @@ -31,6 +32,7 @@ export type ImplementedServiceDeviceTypes = | DeviceType.OSC | DeviceType.PANASONIC_PTZ | DeviceType.SHOTOKU + | DeviceType.SOFIE_CHEF | DeviceType.TCPSEND | DeviceType.QUANTEL @@ -90,6 +92,12 @@ export const DevicesDict: Record = { deviceName: (deviceId: string) => 'Panasonic PTZ ' + deviceId, executionMode: () => 'salvo', }, + [DeviceType.SOFIE_CHEF]: { + deviceClass: SofieChefDevice, + canConnect: true, + deviceName: (deviceId: string) => 'SofieChef ' + deviceId, + executionMode: () => 'salvo', + }, [DeviceType.SHOTOKU]: { deviceClass: ShotokuDevice, canConnect: true,