diff --git a/packages/timeline-state-resolver-types/src/__tests__/__snapshots__/index.spec.ts.snap b/packages/timeline-state-resolver-types/src/__tests__/__snapshots__/index.spec.ts.snap index 81a4ca500..b4a61657e 100644 --- a/packages/timeline-state-resolver-types/src/__tests__/__snapshots__/index.spec.ts.snap +++ b/packages/timeline-state-resolver-types/src/__tests__/__snapshots__/index.spec.ts.snap @@ -70,6 +70,7 @@ exports[`index imports 1`] = ` "TimelineContentTypeTriCaster", "TimelineContentTypeVMix", "TimelineContentTypeVizMSE", + "TimelineContentTypeWebSocketClient", "Transition", "TranslationsBundleType", "TransportStatus", @@ -81,5 +82,6 @@ exports[`index imports 1`] = ` "ViscaOverIPActions", "VizMSEActions", "VmixActions", + "WebsocketClientActions", ] `; diff --git a/packages/timeline-state-resolver-types/src/device.ts b/packages/timeline-state-resolver-types/src/device.ts index 9fe743cb1..1af782dcd 100644 --- a/packages/timeline-state-resolver-types/src/device.ts +++ b/packages/timeline-state-resolver-types/src/device.ts @@ -23,6 +23,7 @@ import { TriCasterOptions, MultiOSCOptions, ViscaOverIPOptions, + WebSocketClientOptions, } from '.' import { DeviceCommonOptions } from './generated/common-options' @@ -78,6 +79,7 @@ export type DeviceOptionsAny = | DeviceOptionsTriCaster | DeviceOptionsMultiOSC | DeviceOptionsViscaOverIP + | DeviceOptionsWebSocketClient export interface DeviceOptionsAbstract extends DeviceOptionsBase { type: DeviceType.ABSTRACT @@ -148,3 +150,7 @@ export interface DeviceOptionsMultiOSC extends DeviceOptionsBase { type: DeviceType.VISCA_OVER_IP } + +export interface DeviceOptionsWebSocketClient extends DeviceOptionsBase { + type: DeviceType.WEBSOCKET_CLIENT +} diff --git a/packages/timeline-state-resolver-types/src/generated/index.ts b/packages/timeline-state-resolver-types/src/generated/index.ts index b48d989db..2f579aafb 100644 --- a/packages/timeline-state-resolver-types/src/generated/index.ts +++ b/packages/timeline-state-resolver-types/src/generated/index.ts @@ -76,6 +76,9 @@ import { SomeMappingVizMSE } from './vizMSE' export * from './vmix' import { SomeMappingVmix } from './vmix' +export * from './websocketClient' +import { SomeMappingWebsocketClient } from './websocketClient' + export type TSRMappingOptions = | SomeMappingAbstract | SomeMappingAtem @@ -100,3 +103,4 @@ export type TSRMappingOptions = | SomeMappingViscaOverIP | SomeMappingVizMSE | SomeMappingVmix + | SomeMappingWebsocketClient diff --git a/packages/timeline-state-resolver-types/src/generated/websocketClient.ts b/packages/timeline-state-resolver-types/src/generated/websocketClient.ts new file mode 100644 index 000000000..825371ab6 --- /dev/null +++ b/packages/timeline-state-resolver-types/src/generated/websocketClient.ts @@ -0,0 +1,62 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run "yarn generate-schema-types" to regenerate this file. + */ +import { ActionExecutionResult } from ".." + +export interface WebSocketClientOptions { + webSocket: { + /** + * URI to connect to, e.g. 'ws://localhost:8080' + */ + uri: string + /** + * Interval between reconnection attempts in milliseconds + */ + reconnectInterval?: number + } + bufferEncoding?: + | 'ascii' + | 'utf8' + | 'utf-8' + | 'utf16le' + | 'ucs2' + | 'ucs-2' + | 'base64' + | 'base64url' + | 'latin1' + | 'binary' + | 'hex' +} + +export type SomeMappingWebsocketClient = Record + +export interface SendWebSocketMessagePayload { + /** + * Message to send over WebSocket + */ + message: string + /** + * Optional queue ID for ordered message handling + */ + queueId?: string +} + +export enum WebsocketClientActions { + Reconnect = 'reconnect', + ResetState = 'resetState', + SendWebSocketMessage = 'sendWebSocketMessage' +} +export interface WebsocketClientActionExecutionResults { + reconnect: () => void, + resetState: () => void, + sendWebSocketMessage: (payload: SendWebSocketMessagePayload) => void +} +export type WebsocketClientActionExecutionPayload = Parameters< + WebsocketClientActionExecutionResults[A] +>[0] + +export type WebsocketClientActionExecutionResult = + ActionExecutionResult> diff --git a/packages/timeline-state-resolver-types/src/index.ts b/packages/timeline-state-resolver-types/src/index.ts index 6655908e5..8717c83f2 100644 --- a/packages/timeline-state-resolver-types/src/index.ts +++ b/packages/timeline-state-resolver-types/src/index.ts @@ -23,6 +23,7 @@ import { TimelineContentSingularLiveAny } from './integrations/singularLive' import { TimelineContentVMixAny } from './integrations/vmix' import { TimelineContentOBSAny } from './integrations/obs' import { TimelineContentTriCasterAny } from './integrations/tricaster' +import { TimelineContentWebSocketClientAny } from './integrations/websocketClient' export * from './integrations/abstract' export * from './integrations/atem' @@ -46,6 +47,7 @@ export * from './integrations/tricaster' export * from './integrations/telemetrics' export * from './integrations/multiOsc' export * from './integrations/viscaOverIP' +export * from './integrations/websocketClient' export * from './device' export * from './mapping' @@ -88,6 +90,7 @@ export enum DeviceType { TRICASTER = 'TRICASTER', MULTI_OSC = 'MULTI_OSC', VISCA_OVER_IP = 'VISCA_OVER_IP', + WEBSOCKET_CLIENT = 'WEBSOCKET_CLIENT', } export interface TSRTimelineKeyframe extends Omit { @@ -149,6 +152,7 @@ export type TSRTimelineContent = | TimelineContentVIZMSEAny | TimelineContentTelemetricsAny | TimelineContentTriCasterAny + | TimelineContentWebSocketClientAny /** * A simple key value store that can be referred to from the timeline objects diff --git a/packages/timeline-state-resolver-types/src/integrations/websocketClient.ts b/packages/timeline-state-resolver-types/src/integrations/websocketClient.ts new file mode 100644 index 000000000..60642ab4a --- /dev/null +++ b/packages/timeline-state-resolver-types/src/integrations/websocketClient.ts @@ -0,0 +1,18 @@ +import { DeviceType } from '..' + +export enum TimelineContentTypeWebSocketClient { + WEBSOCKET_MESSAGE = 'websocketMessage', +} + +export interface TimelineContentWebSocketClientBase { + deviceType: DeviceType.WEBSOCKET_CLIENT + type: TimelineContentTypeWebSocketClient +} + +export interface TimelineContentWebSocketMessage extends TimelineContentWebSocketClientBase { + type: TimelineContentTypeWebSocketClient.WEBSOCKET_MESSAGE + /** Stringified data to send over Websocket connection */ + message: string +} + +export type TimelineContentWebSocketClientAny = TimelineContentWebSocketMessage diff --git a/packages/timeline-state-resolver/src/__tests__/__snapshots__/index.spec.ts.snap b/packages/timeline-state-resolver/src/__tests__/__snapshots__/index.spec.ts.snap index 93c6ff8ae..22e919c9d 100644 --- a/packages/timeline-state-resolver/src/__tests__/__snapshots__/index.spec.ts.snap +++ b/packages/timeline-state-resolver/src/__tests__/__snapshots__/index.spec.ts.snap @@ -83,6 +83,7 @@ exports[`index imports 1`] = ` "TimelineContentTypeTriCaster", "TimelineContentTypeVMix", "TimelineContentTypeVizMSE", + "TimelineContentTypeWebSocketClient", "Transition", "TranslationsBundleType", "TransportStatus", @@ -95,6 +96,7 @@ exports[`index imports 1`] = ` "VizMSEActions", "VizMSEDevice", "VmixActions", + "WebsocketClientActions", "manifest", ] `; diff --git a/packages/timeline-state-resolver/src/conductor.ts b/packages/timeline-state-resolver/src/conductor.ts index b9c63ecc8..5abfcdd86 100644 --- a/packages/timeline-state-resolver/src/conductor.ts +++ b/packages/timeline-state-resolver/src/conductor.ts @@ -44,6 +44,7 @@ import { DeviceOptionsViscaOverIP, DeviceOptionsTriCaster, DeviceOptionsSingularLive, + DeviceOptionsWebSocketClient, } from 'timeline-state-resolver-types' import { DoOnTime } from './devices/doOnTime' @@ -1218,6 +1219,7 @@ export type DeviceOptionsAnyInternal = | DeviceOptionsTelemetrics | DeviceOptionsTriCaster | DeviceOptionsViscaOverIP + | DeviceOptionsWebSocketClient function removeParentFromState( o: Timeline.TimelineState diff --git a/packages/timeline-state-resolver/src/integrations/websocketClient/$schemas/actions.json b/packages/timeline-state-resolver/src/integrations/websocketClient/$schemas/actions.json new file mode 100644 index 000000000..2b6547efa --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/websocketClient/$schemas/actions.json @@ -0,0 +1,40 @@ +{ + "$schema": "../../../$schemas/action-schema.json", + "actions": [ + { + "id": "reconnect", + "name": "Reconnect device", + "destructive": true, + "timeout": 5000 + }, + { + "id": "resetState", + "name": "Reset state", + "destructive": true, + "timeout": 5000 + }, + { + "id": "sendWebSocketMessage", + "name": "Send WebSocket message", + "destructive": false, + "timeout": 5000, + "payload": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Message to send over WebSocket" + }, + "queueId": { + "type": "string", + "description": "Optional queue ID for ordered message handling" + } + }, + "required": [ + "message" + ], + "additionalProperties": false + } + } + ] +} \ No newline at end of file diff --git a/packages/timeline-state-resolver/src/integrations/websocketClient/$schemas/options.json b/packages/timeline-state-resolver/src/integrations/websocketClient/$schemas/options.json new file mode 100644 index 000000000..ca786d9ac --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/websocketClient/$schemas/options.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "WebSocket Client Options", + "type": "object", + "properties": { + "webSocket": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "ui:title": "WebSocket URI", + "ui:description": "URI to connect to, e.g. 'ws://localhost:8080'", + "description": "URI to connect to, e.g. 'ws://localhost:8080'" + }, + "reconnectInterval": { + "type": "integer", + "ui:title": "Reconnect Interval", + "description": "Interval between reconnection attempts in milliseconds", + "default": 5000 + } + }, + "required": [ + "uri" + ], + "additionalProperties": false + }, + "bufferEncoding": { + "type": "string", + "ui:title": "Buffer Encoding", + "enum": [ + "ascii", + "utf8", + "utf-8", + "utf16le", + "ucs2", + "ucs-2", + "base64", + "base64url", + "latin1", + "binary", + "hex" + ], + "default": "utf8" + } + }, + "required": [ + "webSocket" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/packages/timeline-state-resolver/src/integrations/websocketClient/__tests__/websocketClient.spec.ts b/packages/timeline-state-resolver/src/integrations/websocketClient/__tests__/websocketClient.spec.ts new file mode 100644 index 000000000..bacaf07dd --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/websocketClient/__tests__/websocketClient.spec.ts @@ -0,0 +1,171 @@ +import {} from 'timeline-state-resolver-types/dist/integrations/websocketClient' +import { WebSocketConnection } from '../connection' +import { WebSocketClientDevice, WebSocketCommand } from '../index' +import { + Timeline, + DeviceType, + WebSocketClientOptions, + TimelineContentTypeWebSocketClient, + StatusCode, + TSRTimelineContent, +} from 'timeline-state-resolver-types' +import { MockTime } from '../../../__tests__/mockTime' +import { TimelineContentWebSocketClientAny } from 'timeline-state-resolver-types/src' + +// Mock the WebSocketConnection?? +jest.mock('../connection') + +const MockWebSocketConnection = WebSocketConnection as jest.MockedClass + +describe('WebSocketClientDevice', () => { + const mockTime = new MockTime() + let device: WebSocketClientDevice + + beforeEach(async () => { + jest.clearAllMocks() + mockTime.init() + + // Create device context + const deviceContext = { + getCurrentTime: mockTime.getCurrentTime, + getTimeSinceStart: 0, + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, + emit: jest.fn(), + resetStateCheck: jest.fn(), + timeTrace: jest.fn(), + commandError: jest.fn(), + resetToState: jest.fn(), + } + + // Create mock options + const options: WebSocketClientOptions = { + webSocket: { + uri: 'ws://localhost:8080', + reconnectInterval: 5000, + }, + } + + device = new WebSocketClientDevice(deviceContext as any) + + // Mock connection methods + MockWebSocketConnection.prototype.connect.mockResolvedValue() + MockWebSocketConnection.prototype.disconnect.mockResolvedValue() + MockWebSocketConnection.prototype.connected.mockReturnValue(true) + MockWebSocketConnection.prototype.sendWebSocketMessage.mockImplementation() + + // Initialize device + await device.init(options) + }) + + describe('Connections', () => { + test('init', async () => { + const connectSpy = jest.spyOn(MockWebSocketConnection.prototype, 'connect') + expect(connectSpy).toHaveBeenCalled() + }) + + test('terminate', async () => { + const disConnectSpy = jest.spyOn(MockWebSocketConnection.prototype, 'disconnect') + await device.terminate() + expect(disConnectSpy).toHaveBeenCalled() + }) + + test('connected', () => { + expect(device.connected).toBe(true) + + MockWebSocketConnection.prototype.connected.mockReturnValue(false) + expect(device.connected).toBe(false) + }) + + test('getStatus', () => { + MockWebSocketConnection.prototype.connected.mockReturnValue(true) + expect(device.getStatus()).toEqual({ + statusCode: StatusCode.BAD, + messages: ['No Connection'], + }) + + //@ts-expect-error - is set to private + MockWebSocketConnection.prototype.isWsConnected = true + jest.spyOn(WebSocketConnection.prototype, 'connectionStatus').mockReturnValue({ + statusCode: StatusCode.GOOD, + messages: ['WS Connected'], + }) + + //@ts-expect-error - is set to private + MockWebSocketConnection.prototype.isWsConnected = false + jest.spyOn(WebSocketConnection.prototype, 'connectionStatus').mockReturnValue({ + statusCode: StatusCode.BAD, + messages: ['WS DisConnected'], + }) + }) + }) + + describe('Timeline', () => { + test('convertTimelineStateToDeviceState', () => { + const timelineState: Timeline.TimelineState = createTimelineState( + createCommandObject('layer1', 'test ws message') + ) + // As nothings is converted in this device, the result should be the same as the input: + expect(device.convertTimelineStateToDeviceState(timelineState)).toBe(timelineState) + }) + + test('diffStates with WebSocket message command', () => { + const oldState = createTimelineState(createCommandObject('layer1', 'old ws state')) + const newState = createTimelineState(createCommandObject('layer1', 'new test ws message state')) + + const commands = device.diffStates(oldState, newState) + + expect(commands).toHaveLength(1) + expect(commands[0].command.type).toBe(TimelineContentTypeWebSocketClient.WEBSOCKET_MESSAGE) + expect(commands[0].command.message).toBe('new test ws message state') + }) + + test('sendCommand with WebSocket message', async () => { + const command: WebSocketCommand = { + context: 'context', + timelineObjId: 'obj1', + command: { + type: TimelineContentTypeWebSocketClient.WEBSOCKET_MESSAGE, + message: 'test ws message', + }, + } + + await device.sendCommand(command) + + const sendMessageSpy = jest.spyOn(MockWebSocketConnection.prototype, 'sendWebSocketMessage') + expect(sendMessageSpy).toHaveBeenCalledWith('test ws message') + }) + }) +}) + +// Helper functions to create test objects: +function createTimelineState( + objs: Record +): Timeline.TimelineState { + const state: Timeline.TimelineState = { + time: 1000, + layers: objs as any, + nextEvents: [], + } + return state +} + +function createCommandObject( + layerId: string, + message: string +): Record { + return { + [`tcp_${layerId}`]: { + id: `tcp_${layerId}`, + content: { + deviceType: DeviceType.WEBSOCKET_CLIENT, + type: TimelineContentTypeWebSocketClient.WEBSOCKET_MESSAGE, + message: message, // Changed from 'command' to 'message' to match the interface + }, + }, + } +} diff --git a/packages/timeline-state-resolver/src/integrations/websocketClient/connection.ts b/packages/timeline-state-resolver/src/integrations/websocketClient/connection.ts new file mode 100644 index 000000000..b5385523e --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/websocketClient/connection.ts @@ -0,0 +1,78 @@ +import * as WebSocket from 'ws' +import { DeviceStatus, StatusCode, WebSocketClientOptions } from 'timeline-state-resolver-types' + +export class WebSocketConnection { + private ws?: WebSocket + private isWsConnected = false + private readonly options: WebSocketClientOptions + + constructor(options: WebSocketClientOptions) { + this.options = options + } + + async connect(): Promise { + try { + // WebSocket connection + if (this.options.webSocket?.uri) { + this.ws = new WebSocket(this.options.webSocket.uri, this.options.bufferEncoding || 'utf8') + + await new Promise((resolve, reject) => { + if (!this.ws) return reject(new Error('WebSocket not initialized')) + + const timeout = setTimeout(() => { + reject(new Error('WebSocket connection timeout')) + }, this.options.webSocket?.reconnectInterval || 5000) + + this.ws.on('open', () => { + clearTimeout(timeout) + this.isWsConnected = true + resolve() + }) + + this.ws.on('error', (error) => { + clearTimeout(timeout) + reject(error) + }) + }) + + this.ws.on('close', () => { + this.isWsConnected = false + }) + } + } catch (error) { + this.isWsConnected = false + throw error + } + } + + connected(): boolean { + return this.isWsConnected ? true : false + } + + connectionStatus(): Omit { + const messages: string[] = [] + // Prepare for more detailed status messages: + messages.push(this.isWsConnected ? 'WS Connected' : 'WS Disconnected') + return { + statusCode: this.isWsConnected ? StatusCode.GOOD : StatusCode.BAD, + messages, + } + } + + sendWebSocketMessage(message: string | Buffer): void { + if (!this.ws) { + this.isWsConnected = false + throw new Error('WebSocket not connected') + } + this.ws.send(message) + } + + async disconnect(): Promise { + if (this.ws) { + this.ws.close() + this.ws = undefined + } + + this.isWsConnected = false + } +} diff --git a/packages/timeline-state-resolver/src/integrations/websocketClient/index.ts b/packages/timeline-state-resolver/src/integrations/websocketClient/index.ts new file mode 100644 index 000000000..e44c1c815 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/websocketClient/index.ts @@ -0,0 +1,133 @@ +import { CommandWithContext, Device } from '../../service/device' +import { + ActionExecutionResultCode, + DeviceStatus, + DeviceType, + SendWebSocketMessagePayload, + StatusCode, + Timeline, + TimelineContentTypeWebSocketClient, + TSRTimelineContent, + WebSocketClientOptions, +} from 'timeline-state-resolver-types' +import { WebSocketConnection } from './connection' +import { WebsocketClientActions } from 'timeline-state-resolver-types' + +/** this is not an extends but an implementation of the CommandWithContext */ +export interface WebSocketCommand extends CommandWithContext { + command: { + type: TimelineContentTypeWebSocketClient + message: string + } + context: string +} +export type WebSocketClientDeviceState = Timeline.TimelineState + +export class WebSocketClientDevice extends Device< + WebSocketClientOptions, + WebSocketClientDeviceState, + WebSocketCommand +> { + // Use ! as the connection will be initialized in init: + private connection: WebSocketConnection | undefined + + public async init(options: WebSocketClientOptions): Promise { + this.connection = new WebSocketConnection(options) + await this.connection.connect() + return true + } + + readonly actions = { + [WebsocketClientActions.Reconnect]: async (_id: string) => { + await this.connection?.connect() + return { result: ActionExecutionResultCode.Ok } + }, + [WebsocketClientActions.ResetState]: async (_id: string) => { + return { result: ActionExecutionResultCode.Ok } + }, + [WebsocketClientActions.SendWebSocketMessage]: async ( + _id: string, + payload?: Record + ) => { + if (!payload?.message) { + return { result: ActionExecutionResultCode.Error, response: { key: 'Missing message in payload' } } + } + for (const [cmd] of Object.entries(payload)) { + this.connection?.sendWebSocketMessage(cmd) + } + return { result: ActionExecutionResultCode.Ok } + }, + } + + public get connected(): boolean { + return this.connection?.connected() ?? false + } + + public getStatus(): Omit { + return this.connection?.connectionStatus() ?? { statusCode: StatusCode.BAD, messages: ['No Connection'] } + } + + public convertTimelineStateToDeviceState(state: WebSocketClientDeviceState): WebSocketClientDeviceState { + // When a new Timeline State is about to be executed + // This is called to convert the generic Timeline State into a custom "device state". + // For example: + // tl obj: { layer: 'abc', content: { type: TimelineContentTypeWebSocketClient::WEBSOCKET_MESSAGE, message: 'hello'} } + // can be converted into: { websocketMessages: { abc: 'hello } } + // + // This is optional and for convenience only (like to simplify the diffing logic in diffStates()) + + return state + } + + // ** Calculate Diffs of state and create the commands + public diffStates( + oldState: WebSocketClientDeviceState | undefined, + newState: WebSocketClientDeviceState + ): WebSocketCommand[] { + // This is called to calculate and creates the commands needed to make olState reflect newState. + // Note: We DON'T send the commands NOW, but rather we return a list of the commands, to be executed + // later (send to sendCommand() ). + + const commands: WebSocketCommand[] = [] + for (const [layerName, timelineObject] of Object.entries< + Timeline.ResolvedTimelineObjectInstance + >(newState.layers)) { + if (timelineObject.content.deviceType !== DeviceType.WEBSOCKET_CLIENT) continue + + // We should send the command whenever the timeline object content has been ADDED or CHANGED + let changeType = 'N/A' + if (!oldState?.layers[layerName]) { + changeType = 'added' + } else if (JSON.stringify(oldState?.layers[layerName].content) !== JSON.stringify(timelineObject.content)) { + changeType = 'changed' + } else { + continue // no changes + } + + if (timelineObject.content.type === TimelineContentTypeWebSocketClient.WEBSOCKET_MESSAGE) { + commands.push({ + command: { + type: TimelineContentTypeWebSocketClient.WEBSOCKET_MESSAGE, + message: timelineObject.content.message + }, + context: `${changeType} on layer "${layerName}"`, + timelineObjId: timelineObject.id, + }) + } + } + + return commands + } + + public async sendCommand(sendContext: WebSocketCommand): Promise { + if (!sendContext.command) return + let message: string | Buffer = sendContext.command.message + + this.connection?.sendWebSocketMessage(message) + } + + public async terminate(): Promise { + await this.connection?.disconnect() + // Perform any cleanup if needed + } +} diff --git a/packages/timeline-state-resolver/src/manifest.ts b/packages/timeline-state-resolver/src/manifest.ts index fb2311877..d46533c77 100644 --- a/packages/timeline-state-resolver/src/manifest.ts +++ b/packages/timeline-state-resolver/src/manifest.ts @@ -56,6 +56,8 @@ import VizMSEMappings = require('./$schemas/generated/vizMSE/mappings.json') import VMixOptions = require('./$schemas/generated/vmix/options.json') import VMixMappings = require('./$schemas/generated/vmix/mappings.json') import VMixActions = require('./$schemas/generated/vmix/actions.json') +import WebSocketClientOptions = require('./$schemas/generated/websocketClient/options.json') +import WebSocketClientActions = require('./$schemas/generated/websocketClient/actions.json') import CommonOptions = require('./$schemas/common-options.json') import { generateTranslation } from './lib' @@ -214,5 +216,11 @@ export const manifest: TSRManifest = { configSchema: JSON.stringify(VMixOptions), mappingsSchemas: stringifyMappingSchema(VMixMappings), }, + [DeviceType.WEBSOCKET_CLIENT]: { + displayName: generateTranslation('Websocket Client'), + actions: WebSocketClientActions.actions.map(stringifyActionSchema), + configSchema: JSON.stringify(WebSocketClientOptions), + mappingsSchemas: {}, + }, }, } diff --git a/packages/timeline-state-resolver/src/service/ConnectionManager.ts b/packages/timeline-state-resolver/src/service/ConnectionManager.ts index 84e058273..aab2968d0 100644 --- a/packages/timeline-state-resolver/src/service/ConnectionManager.ts +++ b/packages/timeline-state-resolver/src/service/ConnectionManager.ts @@ -436,6 +436,7 @@ function createContainer( case DeviceType.TCPSEND: case DeviceType.TRICASTER: case DeviceType.VISCA_OVER_IP: + case DeviceType.WEBSOCKET_CLIENT: case DeviceType.QUANTEL: { ensureIsImplementedAsService(deviceOptions.type) diff --git a/packages/timeline-state-resolver/src/service/device.ts b/packages/timeline-state-resolver/src/service/device.ts index 1d01a185f..88c92c8f0 100644 --- a/packages/timeline-state-resolver/src/service/device.ts +++ b/packages/timeline-state-resolver/src/service/device.ts @@ -11,8 +11,21 @@ import { type CommandContext = any +/** + * The intended usage of this is to be extended with a device specific type.$ + * Like so: + * export interface MyDeviceCommand extends CommandWithContext { + * command: { myCommandProps } + * context: string + * } + */ export type CommandWithContext = { + /** Device specific command (to be defined by the device itself) */ command: any + /** + * The context is provided for logging / troubleshooting reasons. + * It should contain some kind of explanation as to WHY a command was created (like a reference, path etc.) + */ context: CommandContext /** ID of the timeline-object that the command originated from */ timelineObjId: string @@ -78,19 +91,28 @@ export abstract class Device { /** * This method takes in a Timeline State that describes a point - * in time on the timeline and returns a decice state that - * describes how the device should be according to the timeline state - * + * in time on the timeline and converts it into a "device state" that + * describes how the device should be according to the timeline state. + * Transforming the DeviceState to something else is optional, and are intended to simplify diffing logic. + * The order of TSR is: + * - Timeline Object in Timeline State -> + * - Device State (`convertTimelineStateToDeviceState()`) -> + * - Planned Device Commands (`difStates()`) -> + * - Send Command (`sendCommand()`) * @param state State obj from timeline * @param newMappings Mappings to resolve devices with + * @returns Device state (that is fed into `diffStates()` ) */ convertTimelineStateToDeviceState( state: Timeline.TimelineState, newMappings: Mappings ): DeviceState /** - * This method takes 2 states and returns a set of commands that will - * transition the device from oldState to newState + * This method takes 2 states and returns a set of device-commands that will + * transition the device from oldState to newState. + * + * This is basically the place where we generate the planned commands for the device, + * to be executed later, by `sendCommand()`. */ diffStates( oldState: DeviceState | undefined, diff --git a/packages/timeline-state-resolver/src/service/devices.ts b/packages/timeline-state-resolver/src/service/devices.ts index 5d2e7f7c0..b28d575d9 100644 --- a/packages/timeline-state-resolver/src/service/devices.ts +++ b/packages/timeline-state-resolver/src/service/devices.ts @@ -19,6 +19,7 @@ import { TelemetricsDevice } from '../integrations/telemetrics' import { TriCasterDevice } from '../integrations/tricaster' import { SingularLiveDevice } from '../integrations/singularLive' import { MultiOSCMessageDevice } from '../integrations/multiOsc' +import { WebSocketClientDevice } from '../integrations/websocketClient' export interface DeviceEntry { deviceClass: new (context: DeviceContextAPI) => Device @@ -47,6 +48,7 @@ export type ImplementedServiceDeviceTypes = | DeviceType.TRICASTER | DeviceType.QUANTEL | DeviceType.VISCA_OVER_IP + | DeviceType.WEBSOCKET_CLIENT // TODO - move all device implementations here and remove the old Device classes export const DevicesDict: Record = { @@ -164,4 +166,10 @@ export const DevicesDict: Record = { deviceName: (deviceId: string) => 'VISCAOverIP ' + deviceId, executionMode: () => 'sequential', }, + [DeviceType.WEBSOCKET_CLIENT]: { + deviceClass: WebSocketClientDevice, + canConnect: true, + deviceName: (deviceId: string) => 'WebSocket Client ' + deviceId, + executionMode: () => 'sequential', + }, }