From be244edff730775a39b54161dbdf4f382803ce42 Mon Sep 17 00:00:00 2001 From: olzzon Date: Thu, 20 Feb 2025 13:47:54 +0100 Subject: [PATCH 01/34] feat: WebsocketTcpClient --- .../src/device.ts | 19 +++++ .../src/index.ts | 4 + .../src/integrations/websocketTcpClient.ts | 24 ++++++ .../websocketTcpClient/connection.ts | 65 +++++++++++++++ .../integrations/websocketTcpClient/index.ts | 79 +++++++++++++++++++ .../timeline-state-resolver/src/manifest.ts | 6 ++ 6 files changed, 197 insertions(+) create mode 100644 packages/timeline-state-resolver-types/src/integrations/websocketTcpClient.ts create mode 100644 packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts create mode 100644 packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts diff --git a/packages/timeline-state-resolver-types/src/device.ts b/packages/timeline-state-resolver-types/src/device.ts index 9fe743cb1..47149212a 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, + // currently hardcoded: WebSocketTcpClientOptions } from '.' import { DeviceCommonOptions } from './generated/common-options' @@ -78,6 +79,7 @@ export type DeviceOptionsAny = | DeviceOptionsTriCaster | DeviceOptionsMultiOSC | DeviceOptionsViscaOverIP + | DeviceOptionsWebSocketTcpClient export interface DeviceOptionsAbstract extends DeviceOptionsBase { type: DeviceType.ABSTRACT @@ -148,3 +150,20 @@ export interface DeviceOptionsMultiOSC extends DeviceOptionsBase { type: DeviceType.VISCA_OVER_IP } + +// Move this to a $schema file: +export interface WebSocketTcpClientOptions { + webSocket: { + uri: string; // e.g., "ws://localhost:8080" + reconnectInterval?: number; // Optional, in ms + }; + tcp: { + host: string; // e.g., "127.0.0.1" + port: number; // e.g., 12345 + bufferEncoding?: string; // e.g., "utf8" + }; + } + + export interface DeviceOptionsWebSocketTcpClient extends DeviceOptionsBase { + type: DeviceType.WEBSOCKET_TCP_CLIENT; + } diff --git a/packages/timeline-state-resolver-types/src/index.ts b/packages/timeline-state-resolver-types/src/index.ts index 6655908e5..1477796d6 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 { TimelineContentWebSocketTcpClientAny } from './integrations/websocketTcpClient' 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/websocketTcpClient' 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_TCP_CLIENT = 'WEBSOCKET_TCP_CLIENT', } export interface TSRTimelineKeyframe extends Omit { @@ -149,6 +152,7 @@ export type TSRTimelineContent = | TimelineContentVIZMSEAny | TimelineContentTelemetricsAny | TimelineContentTriCasterAny + | TimelineContentWebSocketTcpClientAny /** * A simple key value store that can be referred to from the timeline objects diff --git a/packages/timeline-state-resolver-types/src/integrations/websocketTcpClient.ts b/packages/timeline-state-resolver-types/src/integrations/websocketTcpClient.ts new file mode 100644 index 000000000..063974b4a --- /dev/null +++ b/packages/timeline-state-resolver-types/src/integrations/websocketTcpClient.ts @@ -0,0 +1,24 @@ +import { DeviceType } from 'timeline-state-resolver-types/src' + +export enum TimelineContentTypeWebSocketTcpClient { + WEBSOCKET_MESSAGE = 'websocketMessage', + TCP_COMMAND = 'tcpCommand', +} + +export interface TimelineContentWebSocketTcpClientBase { + deviceType: DeviceType.WEBSOCKET_TCP_CLIENT + type: TimelineContentTypeWebSocketTcpClient +} + +// We might end up using only 1 datatype as it's the same data being sent over different channels: +export interface TimelineContentWebSocketMessage extends TimelineContentWebSocketTcpClientBase { + type: TimelineContentTypeWebSocketTcpClient.WEBSOCKET_MESSAGE + message: string | Uint8Array // Data to send over WebSocket +} + +export interface TimelineContentTcpCommand extends TimelineContentWebSocketTcpClientBase { + type: TimelineContentTypeWebSocketTcpClient.TCP_COMMAND + command: string | Uint8Array // Data to send over TCP +} + +export type TimelineContentWebSocketTcpClientAny = TimelineContentWebSocketMessage | TimelineContentTcpCommand diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts new file mode 100644 index 000000000..b560927f7 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts @@ -0,0 +1,65 @@ +import * as WebSocket from 'ws' +import { Socket } from 'net' +import { WebSocketTcpClientOptions } from 'timeline-state-resolver-types/src' + +export class WebSocketTcpConnection { + private ws: WebSocket | null = null + private tcp: Socket | null = null + private options: WebSocketTcpClientOptions + + constructor(options: WebSocketTcpClientOptions) { + this.options = options + } + + async connect(): Promise { + // WebSocket connection + this.ws = new WebSocket(this.options.webSocket.uri) + this.ws.on('open', () => console.log('WebSocket connected')) + this.ws.on('error', (err) => console.error('WebSocket error:', err)) + this.ws.on('close', () => { + console.log('WebSocket closed') + if (this.options.webSocket.reconnectInterval) { + setTimeout(() => this.connect(), this.options.webSocket.reconnectInterval) + } + }) + + // TCP connection + this.tcp = new Socket() + this.tcp.connect(this.options.tcp.port, this.options.tcp.host, () => { + console.log('TCP connected') + }) + this.tcp.on('error', (err) => console.error('TCP error:', err)) + this.tcp.on('close', () => console.log('TCP closed')) + } + + connected(): boolean { + return (this.ws?.readyState === WebSocket.OPEN && this.tcp?.writable) || false + } + + sendWebSocketMessage(message: string | Uint8Array): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(message) + } else { + console.warn('WebSocket not connected') + } + } + + sendTcpCommand(command: string | Uint8Array): void { + if (this.tcp?.writable) { + this.tcp.write(command) + } else { + console.warn('TCP not connected') + } + } + + async disconnect(): Promise { + if (this.ws) { + this.ws.close() + this.ws = null + } + if (this.tcp) { + this.tcp.destroy() + this.tcp = null + } + } +} diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts new file mode 100644 index 000000000..0b5663f37 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts @@ -0,0 +1,79 @@ +import { CommandWithContext, Device, DeviceContextAPI } from '../../service/device' +import { DeviceStatus, WebSocketTcpClientOptions } from 'timeline-state-resolver-types' +import { WebSocketTcpConnection } from './connection' + +interface WebSocketTcpCommand extends CommandWithContext{ + command: any // need to fix command structure + context: string + timelineObjId: string + value?: any // +} + +export class WebSocketTcpClientDevice extends Device< + WebSocketTcpClientOptions, + any, //Add state later + WebSocketTcpCommand +> { + private connection: WebSocketTcpConnection + + constructor(context: DeviceContextAPI, _options: WebSocketTcpClientOptions) { + super(context) + this.connection = new WebSocketTcpConnection(_options) + } + + public async init(): Promise { + await this.connection.connect() + return true + } + + public get actions(): any { + // Placeholder implementation + return {} + } + + public get connected(): boolean { + return this.connection?.connected() ?? false + } + + public convertTimelineStateToDeviceState( + state: any // ToDo + ): any { + return state + } + + public diffStates(oldState: any, newState: any): WebSocketTcpCommand[] { + // ToDo: Implement state diffing + const commands: WebSocketTcpCommand[] = [] + if (oldState !== newState) { + commands.push({ + command: 'update', + context: 'state_change', + timelineObjId: 'example_id', + value: newState, + }) + } + return commands + } + + public getStatus(): Omit { + return { + statusCode: this.connected ? 0 : 1, // 0 = GOOD, 1 = BAD (based on StatusCode enum) + messages: this.connected ? ['Connected'] : ['Disconnected'], + } + } + + public async sendCommand(command: WebSocketTcpCommand): Promise { + // Send the command via the WebSocket connection + await this.connection.sendWebSocketMessage(command.value) + } + // We might end up using just one sendCommand() with a switch-case for the command type: + public async sendTcpCommand(command: WebSocketTcpCommand): Promise { + // Send the command via the TCP connection + await this.connection.sendTcpCommand(command.value) + } + + 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..038d1d36e 100644 --- a/packages/timeline-state-resolver/src/manifest.ts +++ b/packages/timeline-state-resolver/src/manifest.ts @@ -214,5 +214,11 @@ export const manifest: TSRManifest = { configSchema: JSON.stringify(VMixOptions), mappingsSchemas: stringifyMappingSchema(VMixMappings), }, + [DeviceType.WEBSOCKET_TCP_CLIENT]: { + displayName: generateTranslation('Websocket+TCP Client'), + // $schema to be added currently hardcoded + configSchema: JSON.stringify({}), + mappingsSchemas: {}, + } }, } From dfb70368b498622a09df6751419e5abf416aa5ee Mon Sep 17 00:00:00 2001 From: olzzon Date: Fri, 21 Feb 2025 10:34:38 +0100 Subject: [PATCH 02/34] feat: websockettcpclient move options to schema --- .../src/device.ts | 17 +------ .../src/generated/index.ts | 4 ++ .../src/generated/websocketTcpClient.ts | 48 ++++++++++++++++++ .../src/integrations/websocketTcpClient.ts | 2 +- .../websocketTcpClient/$schemas/options.json | 50 +++++++++++++++++++ .../websocketTcpClient/connection.ts | 6 +-- .../integrations/websocketTcpClient/index.ts | 6 +-- .../timeline-state-resolver/src/manifest.ts | 3 +- 8 files changed, 113 insertions(+), 23 deletions(-) create mode 100644 packages/timeline-state-resolver-types/src/generated/websocketTcpClient.ts create mode 100644 packages/timeline-state-resolver/src/integrations/websocketTcpClient/$schemas/options.json diff --git a/packages/timeline-state-resolver-types/src/device.ts b/packages/timeline-state-resolver-types/src/device.ts index 47149212a..5585d48f4 100644 --- a/packages/timeline-state-resolver-types/src/device.ts +++ b/packages/timeline-state-resolver-types/src/device.ts @@ -23,7 +23,7 @@ import { TriCasterOptions, MultiOSCOptions, ViscaOverIPOptions, - // currently hardcoded: WebSocketTcpClientOptions + WebSocketTCPClientOptions } from '.' import { DeviceCommonOptions } from './generated/common-options' @@ -150,20 +150,7 @@ export interface DeviceOptionsMultiOSC extends DeviceOptionsBase { type: DeviceType.VISCA_OVER_IP } - -// Move this to a $schema file: -export interface WebSocketTcpClientOptions { - webSocket: { - uri: string; // e.g., "ws://localhost:8080" - reconnectInterval?: number; // Optional, in ms - }; - tcp: { - host: string; // e.g., "127.0.0.1" - port: number; // e.g., 12345 - bufferEncoding?: string; // e.g., "utf8" - }; - } - export interface DeviceOptionsWebSocketTcpClient extends DeviceOptionsBase { + export interface DeviceOptionsWebSocketTcpClient extends DeviceOptionsBase { type: DeviceType.WEBSOCKET_TCP_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..5cf2a90a1 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 './websocketTcpClient' +import { SomeMappingWebsocketTcpClient } from './websocketTcpClient' + export type TSRMappingOptions = | SomeMappingAbstract | SomeMappingAtem @@ -100,3 +103,4 @@ export type TSRMappingOptions = | SomeMappingViscaOverIP | SomeMappingVizMSE | SomeMappingVmix + | SomeMappingWebsocketTcpClient diff --git a/packages/timeline-state-resolver-types/src/generated/websocketTcpClient.ts b/packages/timeline-state-resolver-types/src/generated/websocketTcpClient.ts new file mode 100644 index 000000000..f8e9b8c47 --- /dev/null +++ b/packages/timeline-state-resolver-types/src/generated/websocketTcpClient.ts @@ -0,0 +1,48 @@ +/* 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. + */ + +export interface WebSocketTCPClientOptions { + webSocket: { + /** + * URI to connect to, e.g. 'ws://localhost:8080' + */ + uri: string + /** + * Interval between reconnection attempts in milliseconds + */ + reconnectInterval?: number + [k: string]: unknown + } + tcp: { + /** + * Optional TCP server host to connect to, e.g. '127.0.0.1' - some devices may have a TCP connection too + */ + host: string + /** + * TCP server port to connect to + */ + port: number + /** + * Encoding to use for TCP messages + */ + bufferEncoding?: + | 'ascii' + | 'utf8' + | 'utf-8' + | 'utf16le' + | 'ucs2' + | 'ucs-2' + | 'base64' + | 'base64url' + | 'latin1' + | 'binary' + | 'hex' + [k: string]: unknown + } +} + +export type SomeMappingWebsocketTcpClient = Record diff --git a/packages/timeline-state-resolver-types/src/integrations/websocketTcpClient.ts b/packages/timeline-state-resolver-types/src/integrations/websocketTcpClient.ts index 063974b4a..08ca61c45 100644 --- a/packages/timeline-state-resolver-types/src/integrations/websocketTcpClient.ts +++ b/packages/timeline-state-resolver-types/src/integrations/websocketTcpClient.ts @@ -1,4 +1,4 @@ -import { DeviceType } from 'timeline-state-resolver-types/src' +import { DeviceType } from '..' export enum TimelineContentTypeWebSocketTcpClient { WEBSOCKET_MESSAGE = 'websocketMessage', diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/$schemas/options.json b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/$schemas/options.json new file mode 100644 index 000000000..9f271e511 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/$schemas/options.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "WebSocket & TCP 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"] + }, + "tcp": { + "type": "object", + "properties": { + "host": { + "type": "string", + "ui:title": "Optional - TCP Host", + "description": "Optional TCP server host to connect to, e.g. '127.0.0.1' - some devices may have a TCP connection too" + }, + "port": { + "type": "integer", + "ui:title": "TCP Port", + "description": "TCP server port to connect to" + }, + "bufferEncoding": { + "type": "string", + "ui:title": "Buffer Encoding", + "description": "Encoding to use for TCP messages", + "default": "utf8", + "enum": ["ascii", "utf8", "utf-8", "utf16le", "ucs2", "ucs-2", "base64", "base64url", "latin1", "binary", "hex"] + } + }, + "required": ["host", "port"] + } + }, + "required": ["webSocket", "tcp"], + "additionalProperties": false + } \ No newline at end of file diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts index b560927f7..d27eee2a3 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts @@ -1,13 +1,13 @@ import * as WebSocket from 'ws' import { Socket } from 'net' -import { WebSocketTcpClientOptions } from 'timeline-state-resolver-types/src' +import { WebSocketTCPClientOptions } from 'timeline-state-resolver-types' export class WebSocketTcpConnection { private ws: WebSocket | null = null private tcp: Socket | null = null - private options: WebSocketTcpClientOptions + private options: WebSocketTCPClientOptions - constructor(options: WebSocketTcpClientOptions) { + constructor(options: WebSocketTCPClientOptions) { this.options = options } diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts index 0b5663f37..3929d05bf 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts @@ -1,5 +1,5 @@ import { CommandWithContext, Device, DeviceContextAPI } from '../../service/device' -import { DeviceStatus, WebSocketTcpClientOptions } from 'timeline-state-resolver-types' +import { DeviceStatus, WebSocketTCPClientOptions } from 'timeline-state-resolver-types' import { WebSocketTcpConnection } from './connection' interface WebSocketTcpCommand extends CommandWithContext{ @@ -10,13 +10,13 @@ interface WebSocketTcpCommand extends CommandWithContext{ } export class WebSocketTcpClientDevice extends Device< - WebSocketTcpClientOptions, + WebSocketTCPClientOptions, any, //Add state later WebSocketTcpCommand > { private connection: WebSocketTcpConnection - constructor(context: DeviceContextAPI, _options: WebSocketTcpClientOptions) { + constructor(context: DeviceContextAPI, _options: WebSocketTCPClientOptions) { super(context) this.connection = new WebSocketTcpConnection(_options) } diff --git a/packages/timeline-state-resolver/src/manifest.ts b/packages/timeline-state-resolver/src/manifest.ts index 038d1d36e..259b892b3 100644 --- a/packages/timeline-state-resolver/src/manifest.ts +++ b/packages/timeline-state-resolver/src/manifest.ts @@ -56,6 +56,7 @@ 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 WebSocketTCPClientOptions = require('./$schemas/generated/websocketTcpClient/options.json') import CommonOptions = require('./$schemas/common-options.json') import { generateTranslation } from './lib' @@ -217,7 +218,7 @@ export const manifest: TSRManifest = { [DeviceType.WEBSOCKET_TCP_CLIENT]: { displayName: generateTranslation('Websocket+TCP Client'), // $schema to be added currently hardcoded - configSchema: JSON.stringify({}), + configSchema: JSON.stringify(WebSocketTCPClientOptions), mappingsSchemas: {}, } }, From c2afeebdcad63d9cc135cf332fa0d939fce3d49a Mon Sep 17 00:00:00 2001 From: olzzon Date: Fri, 21 Feb 2025 12:25:43 +0100 Subject: [PATCH 03/34] feat: websockettcpclient move actions to schema --- .../src/generated/websocketTcpClient.ts | 42 +++++++++++ .../websocketTcpClient/$schemas/actions.json | 59 +++++++++++++++ .../integrations/websocketTcpClient/index.ts | 75 +++++++++++-------- .../timeline-state-resolver/src/manifest.ts | 3 +- 4 files changed, 147 insertions(+), 32 deletions(-) create mode 100644 packages/timeline-state-resolver/src/integrations/websocketTcpClient/$schemas/actions.json diff --git a/packages/timeline-state-resolver-types/src/generated/websocketTcpClient.ts b/packages/timeline-state-resolver-types/src/generated/websocketTcpClient.ts index f8e9b8c47..b68336dd8 100644 --- a/packages/timeline-state-resolver-types/src/generated/websocketTcpClient.ts +++ b/packages/timeline-state-resolver-types/src/generated/websocketTcpClient.ts @@ -4,6 +4,7 @@ * 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 WebSocketTCPClientOptions { webSocket: { @@ -46,3 +47,44 @@ export interface WebSocketTCPClientOptions { } export type SomeMappingWebsocketTcpClient = Record + +export interface SendWebSocketMessagePayload { + /** + * Message to send over WebSocket + */ + message: string + /** + * Optional queue ID for ordered message handling + */ + queueId?: string +} + +export interface SendTcpMessagePayload { + /** + * Command to send over TCP + */ + command: string + /** + * Optional queue ID for ordered command handling + */ + queueId?: string +} + +export enum WebsocketTcpClientActions { + Reconnect = 'reconnect', + ResetState = 'resetState', + SendWebSocketMessage = 'sendWebSocketMessage', + SendTcpMessage = 'sendTcpMessage' +} +export interface WebsocketTcpClientActionExecutionResults { + reconnect: () => void, + resetState: () => void, + sendWebSocketMessage: (payload: SendWebSocketMessagePayload) => void, + sendTcpMessage: (payload: SendTcpMessagePayload) => void +} +export type WebsocketTcpClientActionExecutionPayload = Parameters< + WebsocketTcpClientActionExecutionResults[A] +>[0] + +export type WebsocketTcpClientActionExecutionResult = + ActionExecutionResult> diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/$schemas/actions.json b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/$schemas/actions.json new file mode 100644 index 000000000..e33c0048e --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/$schemas/actions.json @@ -0,0 +1,59 @@ +{ + "$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 + } + }, + { + "id": "sendTcpMessage", + "name": "Send TCP Message", + "destructive": false, + "timeout": 5000, + "payload": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "Command to send over TCP" + }, + "queueId": { + "type": "string", + "description": "Optional queue ID for ordered command handling" + } + }, + "required": ["command"], + "additionalProperties": false + } + } + ] + } \ No newline at end of file diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts index 3929d05bf..7352bf019 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts @@ -1,15 +1,17 @@ import { CommandWithContext, Device, DeviceContextAPI } from '../../service/device' -import { DeviceStatus, WebSocketTCPClientOptions } from 'timeline-state-resolver-types' +import { ActionExecutionResultCode, DeviceStatus, WebSocketTCPClientOptions } from 'timeline-state-resolver-types' import { WebSocketTcpConnection } from './connection' +import { WebsocketTcpClientActions } from 'timeline-state-resolver-types' -interface WebSocketTcpCommand extends CommandWithContext{ - command: any // need to fix command structure - context: string - timelineObjId: string - value?: any // -} +export interface WebSocketTcpCommand extends CommandWithContext { + command: 'added' | 'changed' | 'removed' | 'manual' + content: { + message?: string + command?: string + } + } -export class WebSocketTcpClientDevice extends Device< + export class WebSocketTcpClientDevice extends Device< WebSocketTCPClientOptions, any, //Add state later WebSocketTcpCommand @@ -26,9 +28,28 @@ export class WebSocketTcpClientDevice extends Device< return true } - public get actions(): any { - // Placeholder implementation - return {} + readonly actions = { + [WebsocketTcpClientActions.Reconnect]: async (_id: string) => { + await this.connection.connect() + return { result: ActionExecutionResultCode.Ok } + }, + [WebsocketTcpClientActions.ResetState]: async (_id: string) => { + return { result: ActionExecutionResultCode.Ok } + }, + [WebsocketTcpClientActions.SendWebSocketMessage]: async (_id: string, payload?: Record) => { + if (!payload?.message) { + return { result: ActionExecutionResultCode.Error, response: { key: 'Missing message in payload' } } + } + await this.connection.sendWebSocketMessage(payload.message) + return { result: ActionExecutionResultCode.Ok } + }, + [WebsocketTcpClientActions.SendTcpMessage]: async (_id: string, payload?: Record) => { + if (!payload?.command) { + return { result: ActionExecutionResultCode.Error, response: { key: 'Missing command in payload' } } + } + await this.connection.sendTcpMessage(payload.command) + return { result: ActionExecutionResultCode.Ok } + }, } public get connected(): boolean { @@ -41,18 +62,8 @@ export class WebSocketTcpClientDevice extends Device< return state } - public diffStates(oldState: any, newState: any): WebSocketTcpCommand[] { - // ToDo: Implement state diffing - const commands: WebSocketTcpCommand[] = [] - if (oldState !== newState) { - commands.push({ - command: 'update', - context: 'state_change', - timelineObjId: 'example_id', - value: newState, - }) - } - return commands + public diffStates(_oldState: any, _newState: any): WebSocketTcpCommand[] { + return [] } public getStatus(): Omit { @@ -63,14 +74,16 @@ export class WebSocketTcpClientDevice extends Device< } public async sendCommand(command: WebSocketTcpCommand): Promise { - // Send the command via the WebSocket connection - await this.connection.sendWebSocketMessage(command.value) - } - // We might end up using just one sendCommand() with a switch-case for the command type: - public async sendTcpCommand(command: WebSocketTcpCommand): Promise { - // Send the command via the TCP connection - await this.connection.sendTcpCommand(command.value) - } + if (!command.content) return + + if (command.content.message) { + await this.connection.sendWebSocketMessage(command.content.message) + } + + if (command.content.command) { + await this.connection.sendTcpMessage(command.content.command) + } + } public async terminate(): Promise { await this.connection.disconnect() diff --git a/packages/timeline-state-resolver/src/manifest.ts b/packages/timeline-state-resolver/src/manifest.ts index 259b892b3..85f5f4d9c 100644 --- a/packages/timeline-state-resolver/src/manifest.ts +++ b/packages/timeline-state-resolver/src/manifest.ts @@ -57,6 +57,7 @@ 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 WebSocketTCPClientOptions = require('./$schemas/generated/websocketTcpClient/options.json') +import WebSocketTCPClientActions = require('./$schemas/generated/websocketTcpClient/actions.json') import CommonOptions = require('./$schemas/common-options.json') import { generateTranslation } from './lib' @@ -217,7 +218,7 @@ export const manifest: TSRManifest = { }, [DeviceType.WEBSOCKET_TCP_CLIENT]: { displayName: generateTranslation('Websocket+TCP Client'), - // $schema to be added currently hardcoded + actions: WebSocketTCPClientActions.actions.map(stringifyActionSchema), configSchema: JSON.stringify(WebSocketTCPClientOptions), mappingsSchemas: {}, } From 6c64c860129a227548a6dc05ca4a0767a5164142 Mon Sep 17 00:00:00 2001 From: olzzon Date: Fri, 21 Feb 2025 12:54:25 +0100 Subject: [PATCH 04/34] feat: websockettcpclient add basic functionality to connection --- .../websocketTcpClient/connection.ts | 143 +++++++++++------- 1 file changed, 90 insertions(+), 53 deletions(-) diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts index d27eee2a3..8e87666f5 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts @@ -3,63 +3,100 @@ import { Socket } from 'net' import { WebSocketTCPClientOptions } from 'timeline-state-resolver-types' export class WebSocketTcpConnection { - private ws: WebSocket | null = null - private tcp: Socket | null = null - private options: WebSocketTCPClientOptions + private ws?: WebSocket + private tcp?: Socket + private isConnected = false + private readonly options: WebSocketTCPClientOptions - constructor(options: WebSocketTCPClientOptions) { - this.options = options - } + constructor(options: WebSocketTCPClientOptions) { + this.options = options + } - async connect(): Promise { - // WebSocket connection - this.ws = new WebSocket(this.options.webSocket.uri) - this.ws.on('open', () => console.log('WebSocket connected')) - this.ws.on('error', (err) => console.error('WebSocket error:', err)) - this.ws.on('close', () => { - console.log('WebSocket closed') - if (this.options.webSocket.reconnectInterval) { - setTimeout(() => this.connect(), this.options.webSocket.reconnectInterval) - } - }) + async connect(): Promise { + try { + // WebSocket connection + if (this.options.webSocket?.uri) { + this.ws = new WebSocket(this.options.webSocket.uri) + + 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) - // TCP connection - this.tcp = new Socket() - this.tcp.connect(this.options.tcp.port, this.options.tcp.host, () => { - console.log('TCP connected') - }) - this.tcp.on('error', (err) => console.error('TCP error:', err)) - this.tcp.on('close', () => console.log('TCP closed')) - } + this.ws.on('open', () => { + clearTimeout(timeout) + resolve() + }) - connected(): boolean { - return (this.ws?.readyState === WebSocket.OPEN && this.tcp?.writable) || false - } + this.ws.on('error', (error) => { + clearTimeout(timeout) + reject(error) + }) + }) + } - sendWebSocketMessage(message: string | Uint8Array): void { - if (this.ws?.readyState === WebSocket.OPEN) { - this.ws.send(message) - } else { - console.warn('WebSocket not connected') - } - } + // Optional TCP connection + if (this.options.tcp?.host && this.options.tcp?.port) { + this.tcp = new Socket() + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('TCP connection timeout')) + }, 5000) - sendTcpCommand(command: string | Uint8Array): void { - if (this.tcp?.writable) { - this.tcp.write(command) - } else { - console.warn('TCP not connected') - } - } + this.tcp?.connect({ + host: this.options.tcp?.host || '', + port: this.options.tcp?.port || 0 + }, () => { + clearTimeout(timeout) + resolve() + }) - async disconnect(): Promise { - if (this.ws) { - this.ws.close() - this.ws = null - } - if (this.tcp) { - this.tcp.destroy() - this.tcp = null - } - } -} + this.tcp?.on('error', (error) => { + clearTimeout(timeout) + reject(error) + }) + }) + } + + this.isConnected = true + } catch (error) { + this.isConnected = false + throw error + } + } + + connected(): boolean { + return this.isConnected + } + + sendWebSocketMessage(message: string): void { + if (!this.ws) { + throw new Error('WebSocket not connected') + } + this.ws.send(message) + } + + sendTcpMessage(message: string): void { + if (!this.tcp) { + throw new Error('TCP not connected') + } + this.tcp.write(message) + } + + async disconnect(): Promise { + if (this.ws) { + this.ws.close() + this.ws = undefined + } + + if (this.tcp) { + this.tcp.destroy() + this.tcp = undefined + } + + this.isConnected = false + } +} \ No newline at end of file From e75b543dcf56d5c8919d9433144e8252f723c145 Mon Sep 17 00:00:00 2001 From: olzzon Date: Mon, 24 Feb 2025 09:45:28 +0100 Subject: [PATCH 05/34] feat: inital test sketch for websocketTcpClient --- .../__tests__/websocketTcpClient.spec.ts | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts new file mode 100644 index 000000000..9aae10e32 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts @@ -0,0 +1,194 @@ +import { WebSocketTcpClientDevice, WebSocketTcpCommand } from '../index' +import { WebSocketTcpConnection } from '../connection' +import { StatusCode, WebSocketTCPClientOptions } from 'timeline-state-resolver-types' +import * as WebSocket from 'ws' +import { Socket } from 'net' +import { jest } from '@jest/globals' + +jest.mock('ws') +jest.mock('net') + +describe('WebSocketTCPClientDevice', () => { + const mockContext = { + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + } + + const mockOptions: WebSocketTCPClientOptions = { + webSocket: { + uri: 'ws://localhost:8080', + reconnectInterval: 5000, + }, + tcp: { + host: 'localhost', + port: 3000, + bufferEncoding: 'utf8', + }, + } + + let device: WebSocketTcpClientDevice + let mockWs: jest.Mocked + let mockTcp: jest.Mocked + + beforeEach(() => { + jest.clearAllMocks() + device = new WebSocketTcpClientDevice(mockContext as any, mockOptions) + mockWs = new WebSocket('') as jest.Mocked + mockTcp = new Socket() as jest.Mocked + }) + + describe('init()', () => { + it('should initialize successfully', async () => { + const initResult = await device.init() + expect(initResult).toBe(true) + }) + }) + + describe('terminate()', () => { + it('should terminate successfully', async () => { + await device.init() + await device.terminate() + // Verify cleanup was done + }) + }) + + describe('connection status', () => { + it('should report correct connection status', () => { + expect(device.connected).toBe(false) + // Mock connection + //device['connection'].connected = jest.fn().mockReturnValue(true) + expect(device.connected).toBe(true) + }) + + it('should report correct device status', () => { + const status = device.getStatus() + expect(status.statusCode).toBe(StatusCode.UNKNOWN) + + // Mock good connection + //device['connection'].connected = jest.fn().mockReturnValue(true) + expect(device.getStatus().statusCode).toBe(StatusCode.GOOD) + }) + }) + + describe('sendCommand()', () => { + it('should handle WebSocket messages', async () => { + await device.init() + const command: WebSocketTcpCommand = { + // Commans is not yet implemented correctly in websocketTcpClient + command: 'added', + context: 'test', + timelineObjId: 'obj1', + content: { + command: 'webSocket', + message: 'test message', + }, + } + await device.sendCommand(command) + // Verify message was sent + }) + + it('should handle TCP messages', async () => { + await device.init() + // const command: WebSocketTcpCommand = { + // context: 'test', + // timelineObjId: 'obj2', + // command: { + // type: 'tcp', + // command: 'test command', + // }, + // } + // await device.sendCommand(command) + // Verify command was sent + }) + }) +}) + +describe('WebSocketTcpConnection', () => { + const mockOptions: WebSocketTCPClientOptions = { + webSocket: { + uri: 'ws://localhost:8080', + reconnectInterval: 5000, + }, + tcp: { + host: 'localhost', + port: 3000, + bufferEncoding: 'utf8', + }, + } + + let connection: WebSocketTcpConnection + let mockWs: jest.Mocked + let mockTcp: jest.Mocked + + beforeEach(() => { + jest.clearAllMocks() + connection = new WebSocketTcpConnection(mockOptions) + mockWs = new WebSocket('') as jest.Mocked + mockTcp = new Socket() as jest.Mocked + }) + + describe('connect()', () => { + it('should establish WebSocket and TCP connections', async () => { + const connectPromise = connection.connect() + + // ToDo + + await connectPromise + expect(connection.connected()).toBe(true) + }) + + it('should handle connection failures', async () => { + const connectPromise = connection.connect() + + // ToDo + + await expect(connectPromise).rejects.toThrow() + expect(connection.connected()).toBe(false) + }) + }) + + describe('send messages', () => { + beforeEach(async () => { + await connection.connect() + }) + + it('should send WebSocket messages', () => { + const message = 'test message' + connection.sendWebSocketMessage(message) + expect(mockWs.send).toHaveBeenCalledWith(message) + }) + + it('should send TCP messages', () => { + const message = 'test command' + connection.sendTcpMessage(message) + expect(mockTcp.write).toHaveBeenCalledWith(message) + }) + + it('should handle WebSocket send errors', () => { + mockWs.send = jest.fn().mockImplementation(() => { + throw new Error('Send failed') + }) + expect(() => connection.sendWebSocketMessage('test')).toThrow() + }) + + it('should handle TCP send errors', () => { + // ToDo: + expect(() => connection.sendTcpMessage('test')).toThrow() + }) + }) + + describe('disconnect()', () => { + it('should close both connections', async () => { + await connection.connect() + await connection.disconnect() + + expect(mockWs.close).toHaveBeenCalled() + expect(mockTcp.end).toHaveBeenCalled() + expect(connection.connected()).toBe(false) + }) + }) +}) From 8e65e4526afcd7a876eb38e97ed0523a8aff0d7a Mon Sep 17 00:00:00 2001 From: olzzon Date: Mon, 24 Feb 2025 13:48:52 +0100 Subject: [PATCH 06/34] feat: update of documentation and implementation of state and device commands Co-authored-by: Johan Nyman --- .../src/integrations/websocketTcpClient.ts | 18 ++- .../websocketTcpClient/connection.ts | 4 +- .../integrations/websocketTcpClient/index.ts | 110 +++++++++++++++--- .../src/service/device.ts | 34 +++++- 4 files changed, 133 insertions(+), 33 deletions(-) diff --git a/packages/timeline-state-resolver-types/src/integrations/websocketTcpClient.ts b/packages/timeline-state-resolver-types/src/integrations/websocketTcpClient.ts index 08ca61c45..5b9659a2e 100644 --- a/packages/timeline-state-resolver-types/src/integrations/websocketTcpClient.ts +++ b/packages/timeline-state-resolver-types/src/integrations/websocketTcpClient.ts @@ -2,7 +2,7 @@ import { DeviceType } from '..' export enum TimelineContentTypeWebSocketTcpClient { WEBSOCKET_MESSAGE = 'websocketMessage', - TCP_COMMAND = 'tcpCommand', + TCP_MESSAGE = 'tcpMessage', } export interface TimelineContentWebSocketTcpClientBase { @@ -10,15 +10,21 @@ export interface TimelineContentWebSocketTcpClientBase { type: TimelineContentTypeWebSocketTcpClient } -// We might end up using only 1 datatype as it's the same data being sent over different channels: export interface TimelineContentWebSocketMessage extends TimelineContentWebSocketTcpClientBase { - type: TimelineContentTypeWebSocketTcpClient.WEBSOCKET_MESSAGE - message: string | Uint8Array // Data to send over WebSocket + type: TimelineContentTypeWebSocketTcpClient.WEBSOCKET_MESSAGE + /** Stringified data to send over TCP */ + message: string + /** If message contains stringified Base64 binary data or UTF-8 encoded string */ + isBase64Encoded?: boolean } export interface TimelineContentTcpCommand extends TimelineContentWebSocketTcpClientBase { - type: TimelineContentTypeWebSocketTcpClient.TCP_COMMAND - command: string | Uint8Array // Data to send over TCP + type: TimelineContentTypeWebSocketTcpClient.TCP_MESSAGE + /** Stringified data to send over TCP */ + message: string + /** If message contains stringified Base64 binary data or UTF-8 encoded string */ + isBase64Encoded?: boolean + } export type TimelineContentWebSocketTcpClientAny = TimelineContentWebSocketMessage | TimelineContentTcpCommand diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts index 8e87666f5..0c706bc58 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts @@ -72,14 +72,14 @@ export class WebSocketTcpConnection { return this.isConnected } - sendWebSocketMessage(message: string): void { + sendWebSocketMessage(message: string | Buffer): void { if (!this.ws) { throw new Error('WebSocket not connected') } this.ws.send(message) } - sendTcpMessage(message: string): void { + sendTcpMessage(message: string | buffer): void { if (!this.tcp) { throw new Error('TCP not connected') } diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts index 7352bf019..3cf918b2c 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts @@ -1,19 +1,24 @@ import { CommandWithContext, Device, DeviceContextAPI } from '../../service/device' -import { ActionExecutionResultCode, DeviceStatus, WebSocketTCPClientOptions } from 'timeline-state-resolver-types' +import { ActionExecutionResultCode, DeviceStatus, DeviceType, Timeline, TimelineContentTypeWebSocketTcpClient, TSRTimelineContent, WebSocketTCPClientOptions } from 'timeline-state-resolver-types' import { WebSocketTcpConnection } from './connection' import { WebsocketTcpClientActions } from 'timeline-state-resolver-types' +import { isEqual } from 'underscore' + +/** this is not an extends but an implementation of the CommandWithContext */ export interface WebSocketTcpCommand extends CommandWithContext { - command: 'added' | 'changed' | 'removed' | 'manual' - content: { - message?: string - command?: string + command: { + type: TimelineContentTypeWebSocketTcpClient + message: string + isBase64Encoded?: boolean } + context: string } +export type WebSocketTcpClientDeviceState = Timeline.TimelineState export class WebSocketTcpClientDevice extends Device< WebSocketTCPClientOptions, - any, //Add state later + WebSocketTcpClientDeviceState, WebSocketTcpCommand > { private connection: WebSocketTcpConnection @@ -57,33 +62,100 @@ export interface WebSocketTcpCommand extends CommandWithContext { } public convertTimelineStateToDeviceState( - state: any // ToDo - ): any { + state: WebSocketTcpClientDeviceState + ): WebSocketTcpClientDeviceState { + // 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: TimelineContentTypeWebSocketTcpClient::TCP_MESSAGE, message: 'hello'} } + // can be converted into: { tcpMessages: { abc: 'hello } } + // + // This is optional and for convenience only (like to simplify the diffing logic in diffStates()) + return state } - public diffStates(_oldState: any, _newState: any): WebSocketTcpCommand[] { - return [] + // ** Calculate Diffs of state and create the commands + public diffStates(oldState: WebSocketTcpClientDeviceState, newState: WebSocketTcpClientDeviceState): WebSocketTcpCommand[] { + // 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: WebSocketTcpCommand[] = [] + for (const [layerName, timelineObject] of Object.entries(newState.layers)) { + if (timelineObject.content.deviceType !== DeviceType.WEBSOCKET_TCP_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 ( isEqual(oldState.layers[layerName].content, timelineObject.content) ) { + changeType = 'changed' + } else { + continue // no changes + } + + + if (timelineObject.content.type === TimelineContentTypeWebSocketTcpClient.WEBSOCKET_MESSAGE ) { + commands.push({ + command: { + type: TimelineContentTypeWebSocketTcpClient.WEBSOCKET_MESSAGE, + message: timelineObject.content.message, + isBase64Encoded: timelineObject.content.isBase64Encoded + }, + context: `${changeType} on layer "${layerName}"`, + timelineObjId: timelineObject.id, + }) + + } else if (timelineObject.content.type === TimelineContentTypeWebSocketTcpClient.TCP_MESSAGE ) { + + commands.push({ + command: { + type: TimelineContentTypeWebSocketTcpClient.TCP_MESSAGE, + message: timelineObject.content.message, + isBase64Encoded: timelineObject.content.isBase64Encoded + }, + context: `${changeType} on layer "${layerName}"`, + timelineObjId: timelineObject.id, + }) + } + } + + return commands } public getStatus(): Omit { return { statusCode: this.connected ? 0 : 1, // 0 = GOOD, 1 = BAD (based on StatusCode enum) - messages: this.connected ? ['Connected'] : ['Disconnected'], + messages: [ + + + 'Probably okay, todo :)' + //this.connection.isTCPConnected ? 'TCP is Connected' : 'TCP is Disconnected', + //this.connection.isTCPConnected ? 'TCP is Connected' : 'TCP is Disconnected', + ] + + // connected ? ['Connected'] : ['Disconnected'], } } - public async sendCommand(command: WebSocketTcpCommand): Promise { - if (!command.content) return + public async sendCommand(sendContext: WebSocketTcpCommand): Promise { + if (!sendContext.command) return + let message: string | Buffer = sendContext.command.message - if (command.content.message) { - await this.connection.sendWebSocketMessage(command.content.message) + if (sendContext.command.isBase64Encoded) { + // convert base64 to binary + message = Buffer.from(message, 'base64') } - - if (command.content.command) { - await this.connection.sendTcpMessage(command.content.command) + + if (sendContext.command.type === TimelineContentTypeWebSocketTcpClient.WEBSOCKET_MESSAGE) { + await this.connection.sendWebSocketMessage(message) + } else if (sendContext.command.type === TimelineContentTypeWebSocketTcpClient.TCP_MESSAGE ) { + await this.connection.sendTcpMessage(message) } - } + } public async terminate(): Promise { await this.connection.disconnect() diff --git a/packages/timeline-state-resolver/src/service/device.ts b/packages/timeline-state-resolver/src/service/device.ts index 1d01a185f..befb89cf6 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 = { - command: any + /** 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. + * This is optional, and intended to simplify diffing logic in + * 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, From 0ec46a751fbfa9df4a6e5df05040b806f43d68e9 Mon Sep 17 00:00:00 2001 From: olzzon Date: Mon, 24 Feb 2025 13:50:14 +0100 Subject: [PATCH 07/34] fix: typo in type Buffer --- .../src/integrations/websocketTcpClient/connection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts index 0c706bc58..fe6b3dff2 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts @@ -79,7 +79,7 @@ export class WebSocketTcpConnection { this.ws.send(message) } - sendTcpMessage(message: string | buffer): void { + sendTcpMessage(message: string | Buffer): void { if (!this.tcp) { throw new Error('TCP not connected') } From 523ab60d486dea5faba15a20fefe3f89c56d9a73 Mon Sep 17 00:00:00 2001 From: olzzon Date: Mon, 24 Feb 2025 13:55:10 +0100 Subject: [PATCH 08/34] fix: linting --- .../timeline-state-resolver-types/src/device.ts | 10 +++++----- .../src/integrations/websocketTcpClient.ts | 17 ++++++++--------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/timeline-state-resolver-types/src/device.ts b/packages/timeline-state-resolver-types/src/device.ts index 5585d48f4..b1ea77c37 100644 --- a/packages/timeline-state-resolver-types/src/device.ts +++ b/packages/timeline-state-resolver-types/src/device.ts @@ -23,7 +23,7 @@ import { TriCasterOptions, MultiOSCOptions, ViscaOverIPOptions, - WebSocketTCPClientOptions + WebSocketTCPClientOptions, } from '.' import { DeviceCommonOptions } from './generated/common-options' @@ -150,7 +150,7 @@ export interface DeviceOptionsMultiOSC extends DeviceOptionsBase { type: DeviceType.VISCA_OVER_IP } - - export interface DeviceOptionsWebSocketTcpClient extends DeviceOptionsBase { - type: DeviceType.WEBSOCKET_TCP_CLIENT; - } + +export interface DeviceOptionsWebSocketTcpClient extends DeviceOptionsBase { + type: DeviceType.WEBSOCKET_TCP_CLIENT +} diff --git a/packages/timeline-state-resolver-types/src/integrations/websocketTcpClient.ts b/packages/timeline-state-resolver-types/src/integrations/websocketTcpClient.ts index 5b9659a2e..b432b1a84 100644 --- a/packages/timeline-state-resolver-types/src/integrations/websocketTcpClient.ts +++ b/packages/timeline-state-resolver-types/src/integrations/websocketTcpClient.ts @@ -11,20 +11,19 @@ export interface TimelineContentWebSocketTcpClientBase { } export interface TimelineContentWebSocketMessage extends TimelineContentWebSocketTcpClientBase { - type: TimelineContentTypeWebSocketTcpClient.WEBSOCKET_MESSAGE - /** Stringified data to send over TCP */ + type: TimelineContentTypeWebSocketTcpClient.WEBSOCKET_MESSAGE + /** Stringified data to send over TCP */ message: string - /** If message contains stringified Base64 binary data or UTF-8 encoded string */ - isBase64Encoded?: boolean + /** If message contains stringified Base64 binary data or UTF-8 encoded string */ + isBase64Encoded?: boolean } export interface TimelineContentTcpCommand extends TimelineContentWebSocketTcpClientBase { - type: TimelineContentTypeWebSocketTcpClient.TCP_MESSAGE - /** Stringified data to send over TCP */ + type: TimelineContentTypeWebSocketTcpClient.TCP_MESSAGE + /** Stringified data to send over TCP */ message: string - /** If message contains stringified Base64 binary data or UTF-8 encoded string */ - isBase64Encoded?: boolean - + /** If message contains stringified Base64 binary data or UTF-8 encoded string */ + isBase64Encoded?: boolean } export type TimelineContentWebSocketTcpClientAny = TimelineContentWebSocketMessage | TimelineContentTcpCommand From 75c6903412a45dfa7f15fcc94e6cf5888596c5ae Mon Sep 17 00:00:00 2001 From: olzzon Date: Mon, 24 Feb 2025 15:35:48 +0100 Subject: [PATCH 09/34] feat: update tsr types mock to include the websocketTcpClient --- .../src/__tests__/__snapshots__/index.spec.ts.snap | 2 ++ 1 file changed, 2 insertions(+) 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..bd58fc880 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", + "TimelineContentTypeWebSocketTcpClient", "Transition", "TranslationsBundleType", "TransportStatus", @@ -81,5 +82,6 @@ exports[`index imports 1`] = ` "ViscaOverIPActions", "VizMSEActions", "VmixActions", + "WebsocketTcpClientActions", ] `; From e51695c7730e56d792f6c5973c8e87442519c34e Mon Sep 17 00:00:00 2001 From: olzzon Date: Tue, 25 Feb 2025 08:36:10 +0100 Subject: [PATCH 10/34] feat: use sendCommand for better checks --- .../src/integrations/websocketTcpClient/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts index 3cf918b2c..36fc09dfd 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts @@ -45,14 +45,14 @@ export type WebSocketTcpClientDeviceState = Timeline.TimelineState) => { if (!payload?.command) { return { result: ActionExecutionResultCode.Error, response: { key: 'Missing command in payload' } } } - await this.connection.sendTcpMessage(payload.command) + await this.sendCommand(payload.command) return { result: ActionExecutionResultCode.Ok } }, } From 251bb354c1313da6cf00d2e112207abc47b2f50d Mon Sep 17 00:00:00 2001 From: olzzon Date: Tue, 25 Feb 2025 11:59:18 +0100 Subject: [PATCH 11/34] feat: websocketTcpClient test working basics --- .../__tests__/websocketTcpClient.spec.ts | 332 ++++++++++-------- 1 file changed, 180 insertions(+), 152 deletions(-) diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts index 9aae10e32..5d4743a1e 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts @@ -1,194 +1,222 @@ -import { WebSocketTcpClientDevice, WebSocketTcpCommand } from '../index' -import { WebSocketTcpConnection } from '../connection' -import { StatusCode, WebSocketTCPClientOptions } from 'timeline-state-resolver-types' -import * as WebSocket from 'ws' -import { Socket } from 'net' import { jest } from '@jest/globals' - -jest.mock('ws') -jest.mock('net') - -describe('WebSocketTCPClientDevice', () => { - const mockContext = { - logger: { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }, - } - - const mockOptions: WebSocketTCPClientOptions = { - webSocket: { - uri: 'ws://localhost:8080', - reconnectInterval: 5000, - }, - tcp: { - host: 'localhost', - port: 3000, - bufferEncoding: 'utf8', - }, - } - +import {} from 'timeline-state-resolver-types/dist/integrations/websocketTcpClient' +import { WebSocketTcpConnection } from '../connection' +import { WebSocketTcpClientDevice, WebSocketTcpCommand } from '../index' +import { + Timeline, + DeviceType, + WebSocketTCPClientOptions, + TimelineContentTypeWebSocketTcpClient, + StatusCode, + TSRTimelineContent, +} from 'timeline-state-resolver-types' +import { MockTime } from '../../../__tests__/mockTime' +import { TimelineContentWebSocketTcpClientAny } from 'timeline-state-resolver-types/src' + +// Mock the WebSocketTcpConnection?? +jest.mock('../connection') + +const MockWebSocketTcpConnection = WebSocketTcpConnection as jest.MockedClass + +describe('WebSocketTcpClientDevice', () => { + const mockTime = new MockTime() let device: WebSocketTcpClientDevice - let mockWs: jest.Mocked - let mockTcp: jest.Mocked beforeEach(() => { jest.clearAllMocks() - device = new WebSocketTcpClientDevice(mockContext as any, mockOptions) - mockWs = new WebSocket('') as jest.Mocked - mockTcp = new Socket() as jest.Mocked + 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: WebSocketTCPClientOptions = { + webSocket: { + uri: 'ws://localhost:8080', + reconnectInterval: 5000, + }, + tcp: { + host: '127.0.0.1', + port: 1234, + bufferEncoding: 'utf8', + }, + } + + device = new WebSocketTcpClientDevice(deviceContext as any, options) + + // Mock connection methods + MockWebSocketTcpConnection.prototype.connect.mockResolvedValue() + MockWebSocketTcpConnection.prototype.disconnect.mockResolvedValue() + MockWebSocketTcpConnection.prototype.connected.mockReturnValue(true) + MockWebSocketTcpConnection.prototype.sendWebSocketMessage.mockImplementation(() => {}) + MockWebSocketTcpConnection.prototype.sendTcpMessage.mockImplementation(() => {}) }) - describe('init()', () => { - it('should initialize successfully', async () => { - const initResult = await device.init() - expect(initResult).toBe(true) - }) + afterEach(() => { + //Are there something like??: + //mockTime.dispose() + // Or can we just ignore this }) - describe('terminate()', () => { - it('should terminate successfully', async () => { + describe('Connections', () => { + test('init', async () => { await device.init() + expect(MockWebSocketTcpConnection.prototype.connect).toHaveBeenCalled() + }) + + test('terminate', async () => { await device.terminate() - // Verify cleanup was done + expect(MockWebSocketTcpConnection.prototype.disconnect).toHaveBeenCalled() }) - }) - describe('connection status', () => { - it('should report correct connection status', () => { - expect(device.connected).toBe(false) - // Mock connection - //device['connection'].connected = jest.fn().mockReturnValue(true) + test('connected', () => { expect(device.connected).toBe(true) + MockWebSocketTcpConnection.prototype.connected.mockReturnValue(false) + expect(device.connected).toBe(false) }) - it('should report correct device status', () => { - const status = device.getStatus() - expect(status.statusCode).toBe(StatusCode.UNKNOWN) + test('getStatus', () => { + MockWebSocketTcpConnection.prototype.connected.mockReturnValue(true) + expect(device.getStatus()).toEqual({ + statusCode: StatusCode.GOOD, + }) - // Mock good connection - //device['connection'].connected = jest.fn().mockReturnValue(true) - expect(device.getStatus().statusCode).toBe(StatusCode.GOOD) + MockWebSocketTcpConnection.prototype.connected.mockReturnValue(false) + expect(device.getStatus()).toEqual({ + statusCode: StatusCode.BAD, + }) }) }) - describe('sendCommand()', () => { - it('should handle WebSocket messages', async () => { - await device.init() - const command: WebSocketTcpCommand = { - // Commans is not yet implemented correctly in websocketTcpClient - command: 'added', - context: 'test', - timelineObjId: 'obj1', - content: { - command: 'webSocket', - message: 'test message', + describe('Timeline', () => { + test('convertTimelineStateToDeviceState', () => { + const timelineState: Timeline.TimelineState = { + time: 1000, + layers: { + // ToDo - something like this: + // layer1: { + // id: 'id1', + // content: { + // deviceType: DeviceType.WEBSOCKET_TCP_CLIENT, + // type: TimelineContentTypeWebSocketTcpClient.WEBSOCKET_MESSAGE, + // message: 'test ws message', + // }, + // }, }, + nextEvents: [], } - await device.sendCommand(command) - // Verify message was sent - }) - it('should handle TCP messages', async () => { - await device.init() - // const command: WebSocketTcpCommand = { - // context: 'test', - // timelineObjId: 'obj2', - // command: { - // type: 'tcp', - // command: 'test command', - // }, - // } - // await device.sendCommand(command) - // Verify command was sent + expect(device.convertTimelineStateToDeviceState(timelineState)).toBe(timelineState) }) - }) -}) - -describe('WebSocketTcpConnection', () => { - const mockOptions: WebSocketTCPClientOptions = { - webSocket: { - uri: 'ws://localhost:8080', - reconnectInterval: 5000, - }, - tcp: { - host: 'localhost', - port: 3000, - bufferEncoding: 'utf8', - }, - } - - let connection: WebSocketTcpConnection - let mockWs: jest.Mocked - let mockTcp: jest.Mocked - - beforeEach(() => { - jest.clearAllMocks() - connection = new WebSocketTcpConnection(mockOptions) - mockWs = new WebSocket('') as jest.Mocked - mockTcp = new Socket() as jest.Mocked - }) - describe('connect()', () => { - it('should establish WebSocket and TCP connections', async () => { - const connectPromise = connection.connect() + test('diffStates with WebSocket message command', () => { + const oldState = createTimelineState(createWsCommandObject('layer1', 'old ws state')) + const newState = createTimelineState(createWsCommandObject('layer1', 'new test ws message state')) - // ToDo + const commands = device.diffStates(oldState, newState) - await connectPromise - expect(connection.connected()).toBe(true) + expect(commands).toHaveLength(1) + expect(commands[0].command.type).toBe(TimelineContentTypeWebSocketTcpClient.WEBSOCKET_MESSAGE) + expect(commands[0].command.message).toBe('new test ws message state') }) - it('should handle connection failures', async () => { - const connectPromise = connection.connect() + test('diffStates with TCP message command', () => { + const oldState = createTimelineState(createWsCommandObject('layer1', 'old tcp tate')) + const newState = createTimelineState(createTcpCommandObject('layer1', 'new test tcp message state')) - // ToDo + const commands = device.diffStates(oldState, newState) - await expect(connectPromise).rejects.toThrow() - expect(connection.connected()).toBe(false) - }) - }) - - describe('send messages', () => { - beforeEach(async () => { - await connection.connect() + expect(commands).toHaveLength(1) + expect(commands[0].command.type).toBe(TimelineContentTypeWebSocketTcpClient.TCP_MESSAGE) + expect(commands[0].command.message).toBe('new test tcp message state') }) - it('should send WebSocket messages', () => { - const message = 'test message' - connection.sendWebSocketMessage(message) - expect(mockWs.send).toHaveBeenCalledWith(message) - }) + test('sendCommand with WebSocket message', async () => { + const command: WebSocketTcpCommand = { + context: 'context', + timelineObjId: 'obj1', + command: { + type: TimelineContentTypeWebSocketTcpClient.WEBSOCKET_MESSAGE, + message: 'test ws message', + }, + } - it('should send TCP messages', () => { - const message = 'test command' - connection.sendTcpMessage(message) - expect(mockTcp.write).toHaveBeenCalledWith(message) - }) + await device.sendCommand(command) - it('should handle WebSocket send errors', () => { - mockWs.send = jest.fn().mockImplementation(() => { - throw new Error('Send failed') - }) - expect(() => connection.sendWebSocketMessage('test')).toThrow() + expect(MockWebSocketTcpConnection.prototype.sendWebSocketMessage).toHaveBeenCalledWith('test ws message') }) - it('should handle TCP send errors', () => { - // ToDo: - expect(() => connection.sendTcpMessage('test')).toThrow() - }) - }) + test('sendCommand with TCP command', async () => { + const message: WebSocketTcpCommand = { + context: 'context', + timelineObjId: 'obj1', + command: { + type: TimelineContentTypeWebSocketTcpClient.TCP_MESSAGE, + message: 'test tcp message', + }, + } - describe('disconnect()', () => { - it('should close both connections', async () => { - await connection.connect() - await connection.disconnect() + await device.sendCommand(message) - expect(mockWs.close).toHaveBeenCalled() - expect(mockTcp.end).toHaveBeenCalled() - expect(connection.connected()).toBe(false) + expect(MockWebSocketTcpConnection.prototype.sendTcpMessage).toHaveBeenCalledWith('test tcp 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 createWsCommandObject( + layerId: string, + message: string +): Record { + return { + [`tcp_${layerId}`]: { + id: `tcp_${layerId}`, + content: { + deviceType: DeviceType.WEBSOCKET_TCP_CLIENT, + type: TimelineContentTypeWebSocketTcpClient.WEBSOCKET_MESSAGE, + message: message, // Changed from 'command' to 'message' to match the interface + }, + }, + } +} + +function createTcpCommandObject( + layerId: string, + message: string +): Record { + return { + [`tcp_${layerId}`]: { + id: `tcp_${layerId}`, + content: { + deviceType: DeviceType.WEBSOCKET_TCP_CLIENT, + type: TimelineContentTypeWebSocketTcpClient.TCP_MESSAGE, + message: message, // Changed from 'command' to 'message' to match the interface + }, + }, + } +} From 3633b3d578f76281626f8e8a3f5518bf4d964ad9 Mon Sep 17 00:00:00 2001 From: olzzon Date: Tue, 25 Feb 2025 12:28:55 +0100 Subject: [PATCH 12/34] fix: use stringify instead of isEqual --- .../integrations/websocketTcpClient/index.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts index 36fc09dfd..05c0cc9da 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts @@ -1,8 +1,7 @@ import { CommandWithContext, Device, DeviceContextAPI } from '../../service/device' -import { ActionExecutionResultCode, DeviceStatus, DeviceType, Timeline, TimelineContentTypeWebSocketTcpClient, TSRTimelineContent, WebSocketTCPClientOptions } from 'timeline-state-resolver-types' +import { ActionExecutionResultCode, DeviceStatus, DeviceType, StatusCode, Timeline, TimelineContentTypeWebSocketTcpClient, TSRTimelineContent, WebSocketTCPClientOptions } from 'timeline-state-resolver-types' import { WebSocketTcpConnection } from './connection' import { WebsocketTcpClientActions } from 'timeline-state-resolver-types' -import { isEqual } from 'underscore' /** this is not an extends but an implementation of the CommandWithContext */ @@ -91,7 +90,8 @@ export type WebSocketTcpClientDeviceState = Timeline.TimelineState { return { - statusCode: this.connected ? 0 : 1, // 0 = GOOD, 1 = BAD (based on StatusCode enum) - messages: [ + // ToDo implement statuses: + statusCode: this.connected ? StatusCode.GOOD : StatusCode.BAD, + messages: this.connected ? ['Connected'] : ['Disconnected'] - - 'Probably okay, todo :)' + /* + [ + Look into more detaled status messages: //this.connection.isTCPConnected ? 'TCP is Connected' : 'TCP is Disconnected', //this.connection.isTCPConnected ? 'TCP is Connected' : 'TCP is Disconnected', ] + */ - // connected ? ['Connected'] : ['Disconnected'], } } From a9a91da4731ca6a4da987c9de832300a3993ab49 Mon Sep 17 00:00:00 2001 From: olzzon Date: Tue, 25 Feb 2025 12:29:08 +0100 Subject: [PATCH 13/34] fix: test of connection --- .../websocketTcpClient/__tests__/websocketTcpClient.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts index 5d4743a1e..379b62290 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts @@ -93,11 +93,13 @@ describe('WebSocketTcpClientDevice', () => { MockWebSocketTcpConnection.prototype.connected.mockReturnValue(true) expect(device.getStatus()).toEqual({ statusCode: StatusCode.GOOD, + messages: ["Connected"], }) - + MockWebSocketTcpConnection.prototype.connected.mockReturnValue(false) expect(device.getStatus()).toEqual({ statusCode: StatusCode.BAD, + messages: ["Disconnected"], }) }) }) @@ -129,6 +131,7 @@ describe('WebSocketTcpClientDevice', () => { const commands = device.diffStates(oldState, newState) + expect(commands).toHaveLength(1) expect(commands[0].command.type).toBe(TimelineContentTypeWebSocketTcpClient.WEBSOCKET_MESSAGE) expect(commands[0].command.message).toBe('new test ws message state') From 565d9c757e01d2044f9ccd611b4a2a96f3e11a44 Mon Sep 17 00:00:00 2001 From: olzzon Date: Tue, 25 Feb 2025 14:20:10 +0100 Subject: [PATCH 14/34] fix: tcpmessage was not renamed from tcp command in schemas --- .../integrations/websocketTcpClient/$schemas/actions.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/$schemas/actions.json b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/$schemas/actions.json index e33c0048e..e6ef7d762 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/$schemas/actions.json +++ b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/$schemas/actions.json @@ -42,16 +42,16 @@ "payload": { "type": "object", "properties": { - "command": { + "message": { "type": "string", - "description": "Command to send over TCP" + "description": "Message to send over TCP" }, "queueId": { "type": "string", - "description": "Optional queue ID for ordered command handling" + "description": "Optional queue ID for ordered message handling" } }, - "required": ["command"], + "required": ["message"], "additionalProperties": false } } From d045fa36aef3fe73dbdfdd449ecd93015b2b4f6a Mon Sep 17 00:00:00 2001 From: olzzon Date: Tue, 25 Feb 2025 14:21:02 +0100 Subject: [PATCH 15/34] fix: type name in timeline for tcp was still command --- .../src/integrations/websocketTcpClient.ts | 4 ++-- .../__tests__/websocketTcpClient.spec.ts | 19 +++---------------- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/packages/timeline-state-resolver-types/src/integrations/websocketTcpClient.ts b/packages/timeline-state-resolver-types/src/integrations/websocketTcpClient.ts index b432b1a84..97e23cc80 100644 --- a/packages/timeline-state-resolver-types/src/integrations/websocketTcpClient.ts +++ b/packages/timeline-state-resolver-types/src/integrations/websocketTcpClient.ts @@ -18,7 +18,7 @@ export interface TimelineContentWebSocketMessage extends TimelineContentWebSocke isBase64Encoded?: boolean } -export interface TimelineContentTcpCommand extends TimelineContentWebSocketTcpClientBase { +export interface TimelineContentTcpMessage extends TimelineContentWebSocketTcpClientBase { type: TimelineContentTypeWebSocketTcpClient.TCP_MESSAGE /** Stringified data to send over TCP */ message: string @@ -26,4 +26,4 @@ export interface TimelineContentTcpCommand extends TimelineContentWebSocketTcpCl isBase64Encoded?: boolean } -export type TimelineContentWebSocketTcpClientAny = TimelineContentWebSocketMessage | TimelineContentTcpCommand +export type TimelineContentWebSocketTcpClientAny = TimelineContentWebSocketMessage | TimelineContentTcpMessage diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts index 379b62290..c6fc87657 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts @@ -106,22 +106,9 @@ describe('WebSocketTcpClientDevice', () => { describe('Timeline', () => { test('convertTimelineStateToDeviceState', () => { - const timelineState: Timeline.TimelineState = { - time: 1000, - layers: { - // ToDo - something like this: - // layer1: { - // id: 'id1', - // content: { - // deviceType: DeviceType.WEBSOCKET_TCP_CLIENT, - // type: TimelineContentTypeWebSocketTcpClient.WEBSOCKET_MESSAGE, - // message: 'test ws message', - // }, - // }, - }, - nextEvents: [], - } - + const timelineState: Timeline.TimelineState = createTimelineState( + createWsCommandObject('layer1', 'test ws message') + ) expect(device.convertTimelineStateToDeviceState(timelineState)).toBe(timelineState) }) From 9f954d17793a3829a44357ef3a6a0945d7c4c9f6 Mon Sep 17 00:00:00 2001 From: olzzon Date: Tue, 25 Feb 2025 14:23:49 +0100 Subject: [PATCH 16/34] fix: generated websocketTcpClient types updated --- .../src/generated/websocketTcpClient.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/timeline-state-resolver-types/src/generated/websocketTcpClient.ts b/packages/timeline-state-resolver-types/src/generated/websocketTcpClient.ts index b68336dd8..fe3c3dd1a 100644 --- a/packages/timeline-state-resolver-types/src/generated/websocketTcpClient.ts +++ b/packages/timeline-state-resolver-types/src/generated/websocketTcpClient.ts @@ -61,11 +61,11 @@ export interface SendWebSocketMessagePayload { export interface SendTcpMessagePayload { /** - * Command to send over TCP + * Message to send over TCP */ - command: string + message: string /** - * Optional queue ID for ordered command handling + * Optional queue ID for ordered message handling */ queueId?: string } From b6e62e9403bb6441aaf9607cd2c973d1a48d8ce0 Mon Sep 17 00:00:00 2001 From: olzzon Date: Tue, 25 Feb 2025 14:25:30 +0100 Subject: [PATCH 17/34] feat: update comment in test --- .../websocketTcpClient/__tests__/websocketTcpClient.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts index c6fc87657..126cee9d8 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts @@ -109,6 +109,7 @@ describe('WebSocketTcpClientDevice', () => { const timelineState: Timeline.TimelineState = createTimelineState( createWsCommandObject('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) }) From 46f333fc7d1a00cf7f744bbcce984ddb3be811fb Mon Sep 17 00:00:00 2001 From: olzzon Date: Tue, 25 Feb 2025 16:06:13 +0100 Subject: [PATCH 18/34] feat: connection status for websocketTcpClient --- .../websocketTcpClient/connection.ts | 44 ++++++++++++++++--- .../integrations/websocketTcpClient/index.ts | 21 ++------- 2 files changed, 42 insertions(+), 23 deletions(-) diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts index fe6b3dff2..4a5a3b2e5 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts @@ -1,11 +1,12 @@ import * as WebSocket from 'ws' import { Socket } from 'net' -import { WebSocketTCPClientOptions } from 'timeline-state-resolver-types' +import { DeviceStatus, StatusCode, WebSocketTCPClientOptions } from 'timeline-state-resolver-types' export class WebSocketTcpConnection { private ws?: WebSocket private tcp?: Socket - private isConnected = false + private isWsConnected = false + private isTcpConnected = false private readonly options: WebSocketTCPClientOptions constructor(options: WebSocketTCPClientOptions) { @@ -27,6 +28,7 @@ export class WebSocketTcpConnection { this.ws.on('open', () => { clearTimeout(timeout) + this.isWsConnected = true resolve() }) @@ -35,6 +37,10 @@ export class WebSocketTcpConnection { reject(error) }) }) + + this.ws.on('close', () => { + this.isWsConnected = false + }) } // Optional TCP connection @@ -55,25 +61,49 @@ export class WebSocketTcpConnection { }) this.tcp?.on('error', (error) => { + this.isTcpConnected = false clearTimeout(timeout) reject(error) }) + + this.tcp?.on('close', () => { + this.isTcpConnected = false + }) }) } - this.isConnected = true + this.isTcpConnected = true } catch (error) { - this.isConnected = false + this.isTcpConnected = false throw error } } connected(): boolean { - return this.isConnected + // Check if both WebSocket and TCP connections are active + // And only use the isTcpConnected flag if the TCP connection is defined + const isConnected = this.isWsConnected && (this.tcp ? this.isTcpConnected : true) + return isConnected + } + + connectionStatus(): Omit { + // Check if both WebSocket and TCP connections are active + // And only use the isTcpConnected flag if the TCP connection is defined + const isConnected = this.isWsConnected && (this.tcp ? this.isTcpConnected : true) + let messages: string[] = [] + messages.push(this.isWsConnected ? 'WS Connected' : 'WS Disconnected') + if (this.tcp) { + messages.push(this.isTcpConnected ? 'TCP Connected' : 'TCP Disconnected') + } + return { + statusCode: isConnected ? 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) @@ -81,6 +111,7 @@ export class WebSocketTcpConnection { sendTcpMessage(message: string | Buffer): void { if (!this.tcp) { + this.isTcpConnected = false throw new Error('TCP not connected') } this.tcp.write(message) @@ -97,6 +128,7 @@ export class WebSocketTcpConnection { this.tcp = undefined } - this.isConnected = false + this.isWsConnected = false + this.isTcpConnected = false } } \ No newline at end of file diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts index 05c0cc9da..cfd2d5c41 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts @@ -60,6 +60,10 @@ export type WebSocketTcpClientDeviceState = Timeline.TimelineState { + return this.connection?.connectionStatus() ?? { statusCode: StatusCode.BAD, messages: ['No Connection'] } + } + public convertTimelineStateToDeviceState( state: WebSocketTcpClientDeviceState ): WebSocketTcpClientDeviceState { @@ -126,23 +130,6 @@ export type WebSocketTcpClientDeviceState = Timeline.TimelineState { - return { - // ToDo implement statuses: - statusCode: this.connected ? StatusCode.GOOD : StatusCode.BAD, - messages: this.connected ? ['Connected'] : ['Disconnected'] - - /* - [ - Look into more detaled status messages: - //this.connection.isTCPConnected ? 'TCP is Connected' : 'TCP is Disconnected', - //this.connection.isTCPConnected ? 'TCP is Connected' : 'TCP is Disconnected', - ] - */ - - } - } - public async sendCommand(sendContext: WebSocketTcpCommand): Promise { if (!sendContext.command) return let message: string | Buffer = sendContext.command.message From 391623d578fbc4fa92816bc9813576416c2be1bc Mon Sep 17 00:00:00 2001 From: olzzon Date: Tue, 25 Feb 2025 17:47:53 +0100 Subject: [PATCH 19/34] feat: websockettcpclient test connection status --- .../__tests__/websocketTcpClient.spec.ts | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts index 126cee9d8..ea789a64e 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts @@ -85,6 +85,7 @@ describe('WebSocketTcpClientDevice', () => { test('connected', () => { expect(device.connected).toBe(true) + MockWebSocketTcpConnection.prototype.connected.mockReturnValue(false) expect(device.connected).toBe(false) }) @@ -92,15 +93,28 @@ describe('WebSocketTcpClientDevice', () => { test('getStatus', () => { MockWebSocketTcpConnection.prototype.connected.mockReturnValue(true) expect(device.getStatus()).toEqual({ + statusCode: StatusCode.BAD, + messages: ["No Connection"], + }) + + //@ts-expect-error - is set to private + MockWebSocketTcpConnection.prototype.isTcpConnected = true + //@ts-expect-error - is set to private + MockWebSocketTcpConnection.prototype.isWsConnected = true + jest.spyOn(WebSocketTcpConnection.prototype, 'connectionStatus').mockReturnValue({ statusCode: StatusCode.GOOD, - messages: ["Connected"], + messages: ["WS Connected", "TCP Connected"], }) - - MockWebSocketTcpConnection.prototype.connected.mockReturnValue(false) - expect(device.getStatus()).toEqual({ + + //@ts-expect-error - is set to private + MockWebSocketTcpConnection.prototype.isTcpConnected = false + //@ts-expect-error - is set to private + MockWebSocketTcpConnection.prototype.isWsConnected = true + jest.spyOn(WebSocketTcpConnection.prototype, 'connectionStatus').mockReturnValue({ statusCode: StatusCode.BAD, - messages: ["Disconnected"], + messages: ["WS DisConnected", "TCP Connected"], }) + }) }) From 973fa25bef422e8dc26b544569096b9346412eb0 Mon Sep 17 00:00:00 2001 From: olzzon Date: Wed, 26 Feb 2025 08:44:52 +0100 Subject: [PATCH 20/34] fix: add websocketTcpClient to services in tsr --- .../src/integrations/websocketTcpClient/index.ts | 11 ++++++----- .../timeline-state-resolver/src/service/devices.ts | 8 ++++++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts index cfd2d5c41..0784761b0 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts @@ -20,14 +20,15 @@ export type WebSocketTcpClientDeviceState = Timeline.TimelineState { - private connection: WebSocketTcpConnection + // Use ! as the connection will be initialized in init: + private connection!: WebSocketTcpConnection - constructor(context: DeviceContextAPI, _options: WebSocketTCPClientOptions) { + constructor(context: DeviceContextAPI) { super(context) - this.connection = new WebSocketTcpConnection(_options) } - - public async init(): Promise { + + public async init(options: WebSocketTCPClientOptions): Promise { + this.connection = new WebSocketTcpConnection(options) await this.connection.connect() return true } diff --git a/packages/timeline-state-resolver/src/service/devices.ts b/packages/timeline-state-resolver/src/service/devices.ts index 5d2e7f7c0..9e1a58cf8 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 { WebSocketTcpClientDevice } from '../integrations/websocketTcpClient' 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_TCP_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_TCP_CLIENT]: { + deviceClass: WebSocketTcpClientDevice, + canConnect: true, + deviceName: (deviceId: string) => 'WebSocket TCP Client ' + deviceId, + executionMode: () => 'sequential', + } } From ca4671a3d1a2191ab384aa6ff2753a07b4246faa Mon Sep 17 00:00:00 2001 From: olzzon Date: Wed, 26 Feb 2025 09:08:58 +0100 Subject: [PATCH 21/34] fix: add websocketTcpClient to connection manager --- .../src/__tests__/__snapshots__/index.spec.ts.snap | 2 ++ packages/timeline-state-resolver/src/conductor.ts | 2 ++ .../timeline-state-resolver/src/service/ConnectionManager.ts | 1 + 3 files changed, 5 insertions(+) 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..26d7ba4a9 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", + "TimelineContentTypeWebSocketTcpClient", "Transition", "TranslationsBundleType", "TransportStatus", @@ -95,6 +96,7 @@ exports[`index imports 1`] = ` "VizMSEActions", "VizMSEDevice", "VmixActions", + "WebsocketTcpClientActions", "manifest", ] `; diff --git a/packages/timeline-state-resolver/src/conductor.ts b/packages/timeline-state-resolver/src/conductor.ts index b9c63ecc8..17f9d494c 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, + DeviceOptionsWebSocketTcpClient, } from 'timeline-state-resolver-types' import { DoOnTime } from './devices/doOnTime' @@ -1218,6 +1219,7 @@ export type DeviceOptionsAnyInternal = | DeviceOptionsTelemetrics | DeviceOptionsTriCaster | DeviceOptionsViscaOverIP + | DeviceOptionsWebSocketTcpClient function removeParentFromState( o: Timeline.TimelineState diff --git a/packages/timeline-state-resolver/src/service/ConnectionManager.ts b/packages/timeline-state-resolver/src/service/ConnectionManager.ts index 84e058273..be7dc1738 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_TCP_CLIENT: case DeviceType.QUANTEL: { ensureIsImplementedAsService(deviceOptions.type) From 6601ff3ed26e6f03c198de124315e9a926ead1db Mon Sep 17 00:00:00 2001 From: olzzon Date: Wed, 26 Feb 2025 09:19:08 +0100 Subject: [PATCH 22/34] fix: websocketTcpClient tests to use refactored init() --- .../__tests__/websocketTcpClient.spec.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts index ea789a64e..2d235107c 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts @@ -22,7 +22,7 @@ describe('WebSocketTcpClientDevice', () => { const mockTime = new MockTime() let device: WebSocketTcpClientDevice - beforeEach(() => { + beforeEach(async () => { jest.clearAllMocks() mockTime.init() @@ -56,7 +56,7 @@ describe('WebSocketTcpClientDevice', () => { }, } - device = new WebSocketTcpClientDevice(deviceContext as any, options) + device = new WebSocketTcpClientDevice(deviceContext as any) // Mock connection methods MockWebSocketTcpConnection.prototype.connect.mockResolvedValue() @@ -64,6 +64,9 @@ describe('WebSocketTcpClientDevice', () => { MockWebSocketTcpConnection.prototype.connected.mockReturnValue(true) MockWebSocketTcpConnection.prototype.sendWebSocketMessage.mockImplementation(() => {}) MockWebSocketTcpConnection.prototype.sendTcpMessage.mockImplementation(() => {}) + + // Initialize device + await device.init( options ) }) afterEach(() => { @@ -74,7 +77,6 @@ describe('WebSocketTcpClientDevice', () => { describe('Connections', () => { test('init', async () => { - await device.init() expect(MockWebSocketTcpConnection.prototype.connect).toHaveBeenCalled() }) From 990c86838f4723f02c71cceb378e06179143ca90 Mon Sep 17 00:00:00 2001 From: olzzon Date: Wed, 26 Feb 2025 10:39:49 +0100 Subject: [PATCH 23/34] fix: remove encoding option as this is included in command --- .../src/generated/websocketTcpClient.ts | 15 --------------- .../websocketTcpClient/$schemas/options.json | 7 ------- 2 files changed, 22 deletions(-) diff --git a/packages/timeline-state-resolver-types/src/generated/websocketTcpClient.ts b/packages/timeline-state-resolver-types/src/generated/websocketTcpClient.ts index fe3c3dd1a..8f29884c7 100644 --- a/packages/timeline-state-resolver-types/src/generated/websocketTcpClient.ts +++ b/packages/timeline-state-resolver-types/src/generated/websocketTcpClient.ts @@ -27,21 +27,6 @@ export interface WebSocketTCPClientOptions { * TCP server port to connect to */ port: number - /** - * Encoding to use for TCP messages - */ - bufferEncoding?: - | 'ascii' - | 'utf8' - | 'utf-8' - | 'utf16le' - | 'ucs2' - | 'ucs-2' - | 'base64' - | 'base64url' - | 'latin1' - | 'binary' - | 'hex' [k: string]: unknown } } diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/$schemas/options.json b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/$schemas/options.json index 9f271e511..82d12ad7d 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/$schemas/options.json +++ b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/$schemas/options.json @@ -33,13 +33,6 @@ "type": "integer", "ui:title": "TCP Port", "description": "TCP server port to connect to" - }, - "bufferEncoding": { - "type": "string", - "ui:title": "Buffer Encoding", - "description": "Encoding to use for TCP messages", - "default": "utf8", - "enum": ["ascii", "utf8", "utf-8", "utf16le", "ucs2", "ucs-2", "base64", "base64url", "latin1", "binary", "hex"] } }, "required": ["host", "port"] From 0b61576e09193fa8c41221a032e06895e9a0a3dd Mon Sep 17 00:00:00 2001 From: olzzon Date: Fri, 28 Feb 2025 08:33:27 +0100 Subject: [PATCH 24/34] feat: remove optional tcp in websocket connection --- .../__snapshots__/index.spec.ts.snap | 4 +- .../src/device.ts | 8 +- .../src/generated/index.ts | 6 +- .../src/generated/websocketClient.ts | 51 ++++ .../src/generated/websocketTcpClient.ts | 75 ------ .../src/index.ts | 8 +- .../src/integrations/websocketClient.ts | 20 ++ .../src/integrations/websocketTcpClient.ts | 29 --- .../__snapshots__/index.spec.ts.snap | 4 +- .../timeline-state-resolver/src/conductor.ts | 4 +- .../$schemas/actions.json | 21 -- .../$schemas/options.json | 20 +- .../__tests__/websocketClient.spec.ts | 179 ++++++++++++++ .../websocketClient/connection.ts | 78 ++++++ .../index.ts | 79 +++--- .../__tests__/websocketTcpClient.spec.ts | 229 ------------------ .../websocketTcpClient/connection.ts | 134 ---------- .../timeline-state-resolver/src/manifest.ts | 12 +- .../src/service/ConnectionManager.ts | 2 +- .../src/service/devices.ts | 10 +- 20 files changed, 387 insertions(+), 586 deletions(-) create mode 100644 packages/timeline-state-resolver-types/src/generated/websocketClient.ts delete mode 100644 packages/timeline-state-resolver-types/src/generated/websocketTcpClient.ts create mode 100644 packages/timeline-state-resolver-types/src/integrations/websocketClient.ts delete mode 100644 packages/timeline-state-resolver-types/src/integrations/websocketTcpClient.ts rename packages/timeline-state-resolver/src/integrations/{websocketTcpClient => websocketClient}/$schemas/actions.json (61%) rename packages/timeline-state-resolver/src/integrations/{websocketTcpClient => websocketClient}/$schemas/options.json (55%) create mode 100644 packages/timeline-state-resolver/src/integrations/websocketClient/__tests__/websocketClient.spec.ts create mode 100644 packages/timeline-state-resolver/src/integrations/websocketClient/connection.ts rename packages/timeline-state-resolver/src/integrations/{websocketTcpClient => websocketClient}/index.ts (54%) delete mode 100644 packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts delete mode 100644 packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts 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 bd58fc880..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,7 +70,7 @@ exports[`index imports 1`] = ` "TimelineContentTypeTriCaster", "TimelineContentTypeVMix", "TimelineContentTypeVizMSE", - "TimelineContentTypeWebSocketTcpClient", + "TimelineContentTypeWebSocketClient", "Transition", "TranslationsBundleType", "TransportStatus", @@ -82,6 +82,6 @@ exports[`index imports 1`] = ` "ViscaOverIPActions", "VizMSEActions", "VmixActions", - "WebsocketTcpClientActions", + "WebsocketClientActions", ] `; diff --git a/packages/timeline-state-resolver-types/src/device.ts b/packages/timeline-state-resolver-types/src/device.ts index b1ea77c37..1af782dcd 100644 --- a/packages/timeline-state-resolver-types/src/device.ts +++ b/packages/timeline-state-resolver-types/src/device.ts @@ -23,7 +23,7 @@ import { TriCasterOptions, MultiOSCOptions, ViscaOverIPOptions, - WebSocketTCPClientOptions, + WebSocketClientOptions, } from '.' import { DeviceCommonOptions } from './generated/common-options' @@ -79,7 +79,7 @@ export type DeviceOptionsAny = | DeviceOptionsTriCaster | DeviceOptionsMultiOSC | DeviceOptionsViscaOverIP - | DeviceOptionsWebSocketTcpClient + | DeviceOptionsWebSocketClient export interface DeviceOptionsAbstract extends DeviceOptionsBase { type: DeviceType.ABSTRACT @@ -151,6 +151,6 @@ export interface DeviceOptionsViscaOverIP extends DeviceOptionsBase { - type: DeviceType.WEBSOCKET_TCP_CLIENT +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 5cf2a90a1..2f579aafb 100644 --- a/packages/timeline-state-resolver-types/src/generated/index.ts +++ b/packages/timeline-state-resolver-types/src/generated/index.ts @@ -76,8 +76,8 @@ import { SomeMappingVizMSE } from './vizMSE' export * from './vmix' import { SomeMappingVmix } from './vmix' -export * from './websocketTcpClient' -import { SomeMappingWebsocketTcpClient } from './websocketTcpClient' +export * from './websocketClient' +import { SomeMappingWebsocketClient } from './websocketClient' export type TSRMappingOptions = | SomeMappingAbstract @@ -103,4 +103,4 @@ export type TSRMappingOptions = | SomeMappingViscaOverIP | SomeMappingVizMSE | SomeMappingVmix - | SomeMappingWebsocketTcpClient + | 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..caaaa06ca --- /dev/null +++ b/packages/timeline-state-resolver-types/src/generated/websocketClient.ts @@ -0,0 +1,51 @@ +/* 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 + [k: string]: unknown + } +} + +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/generated/websocketTcpClient.ts b/packages/timeline-state-resolver-types/src/generated/websocketTcpClient.ts deleted file mode 100644 index 8f29884c7..000000000 --- a/packages/timeline-state-resolver-types/src/generated/websocketTcpClient.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* 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 WebSocketTCPClientOptions { - webSocket: { - /** - * URI to connect to, e.g. 'ws://localhost:8080' - */ - uri: string - /** - * Interval between reconnection attempts in milliseconds - */ - reconnectInterval?: number - [k: string]: unknown - } - tcp: { - /** - * Optional TCP server host to connect to, e.g. '127.0.0.1' - some devices may have a TCP connection too - */ - host: string - /** - * TCP server port to connect to - */ - port: number - [k: string]: unknown - } -} - -export type SomeMappingWebsocketTcpClient = Record - -export interface SendWebSocketMessagePayload { - /** - * Message to send over WebSocket - */ - message: string - /** - * Optional queue ID for ordered message handling - */ - queueId?: string -} - -export interface SendTcpMessagePayload { - /** - * Message to send over TCP - */ - message: string - /** - * Optional queue ID for ordered message handling - */ - queueId?: string -} - -export enum WebsocketTcpClientActions { - Reconnect = 'reconnect', - ResetState = 'resetState', - SendWebSocketMessage = 'sendWebSocketMessage', - SendTcpMessage = 'sendTcpMessage' -} -export interface WebsocketTcpClientActionExecutionResults { - reconnect: () => void, - resetState: () => void, - sendWebSocketMessage: (payload: SendWebSocketMessagePayload) => void, - sendTcpMessage: (payload: SendTcpMessagePayload) => void -} -export type WebsocketTcpClientActionExecutionPayload = Parameters< - WebsocketTcpClientActionExecutionResults[A] ->[0] - -export type WebsocketTcpClientActionExecutionResult = - ActionExecutionResult> diff --git a/packages/timeline-state-resolver-types/src/index.ts b/packages/timeline-state-resolver-types/src/index.ts index 1477796d6..8717c83f2 100644 --- a/packages/timeline-state-resolver-types/src/index.ts +++ b/packages/timeline-state-resolver-types/src/index.ts @@ -23,7 +23,7 @@ import { TimelineContentSingularLiveAny } from './integrations/singularLive' import { TimelineContentVMixAny } from './integrations/vmix' import { TimelineContentOBSAny } from './integrations/obs' import { TimelineContentTriCasterAny } from './integrations/tricaster' -import { TimelineContentWebSocketTcpClientAny } from './integrations/websocketTcpClient' +import { TimelineContentWebSocketClientAny } from './integrations/websocketClient' export * from './integrations/abstract' export * from './integrations/atem' @@ -47,7 +47,7 @@ export * from './integrations/tricaster' export * from './integrations/telemetrics' export * from './integrations/multiOsc' export * from './integrations/viscaOverIP' -export * from './integrations/websocketTcpClient' +export * from './integrations/websocketClient' export * from './device' export * from './mapping' @@ -90,7 +90,7 @@ export enum DeviceType { TRICASTER = 'TRICASTER', MULTI_OSC = 'MULTI_OSC', VISCA_OVER_IP = 'VISCA_OVER_IP', - WEBSOCKET_TCP_CLIENT = 'WEBSOCKET_TCP_CLIENT', + WEBSOCKET_CLIENT = 'WEBSOCKET_CLIENT', } export interface TSRTimelineKeyframe extends Omit { @@ -152,7 +152,7 @@ export type TSRTimelineContent = | TimelineContentVIZMSEAny | TimelineContentTelemetricsAny | TimelineContentTriCasterAny - | TimelineContentWebSocketTcpClientAny + | 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..684f33ac1 --- /dev/null +++ b/packages/timeline-state-resolver-types/src/integrations/websocketClient.ts @@ -0,0 +1,20 @@ +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 + /** If message contains stringified Base64 binary data or UTF-8 encoded string */ + isBase64Encoded?: boolean +} + +export type TimelineContentWebSocketClientAny = TimelineContentWebSocketMessage diff --git a/packages/timeline-state-resolver-types/src/integrations/websocketTcpClient.ts b/packages/timeline-state-resolver-types/src/integrations/websocketTcpClient.ts deleted file mode 100644 index 97e23cc80..000000000 --- a/packages/timeline-state-resolver-types/src/integrations/websocketTcpClient.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { DeviceType } from '..' - -export enum TimelineContentTypeWebSocketTcpClient { - WEBSOCKET_MESSAGE = 'websocketMessage', - TCP_MESSAGE = 'tcpMessage', -} - -export interface TimelineContentWebSocketTcpClientBase { - deviceType: DeviceType.WEBSOCKET_TCP_CLIENT - type: TimelineContentTypeWebSocketTcpClient -} - -export interface TimelineContentWebSocketMessage extends TimelineContentWebSocketTcpClientBase { - type: TimelineContentTypeWebSocketTcpClient.WEBSOCKET_MESSAGE - /** Stringified data to send over TCP */ - message: string - /** If message contains stringified Base64 binary data or UTF-8 encoded string */ - isBase64Encoded?: boolean -} - -export interface TimelineContentTcpMessage extends TimelineContentWebSocketTcpClientBase { - type: TimelineContentTypeWebSocketTcpClient.TCP_MESSAGE - /** Stringified data to send over TCP */ - message: string - /** If message contains stringified Base64 binary data or UTF-8 encoded string */ - isBase64Encoded?: boolean -} - -export type TimelineContentWebSocketTcpClientAny = TimelineContentWebSocketMessage | TimelineContentTcpMessage 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 26d7ba4a9..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,7 +83,7 @@ exports[`index imports 1`] = ` "TimelineContentTypeTriCaster", "TimelineContentTypeVMix", "TimelineContentTypeVizMSE", - "TimelineContentTypeWebSocketTcpClient", + "TimelineContentTypeWebSocketClient", "Transition", "TranslationsBundleType", "TransportStatus", @@ -96,7 +96,7 @@ exports[`index imports 1`] = ` "VizMSEActions", "VizMSEDevice", "VmixActions", - "WebsocketTcpClientActions", + "WebsocketClientActions", "manifest", ] `; diff --git a/packages/timeline-state-resolver/src/conductor.ts b/packages/timeline-state-resolver/src/conductor.ts index 17f9d494c..5abfcdd86 100644 --- a/packages/timeline-state-resolver/src/conductor.ts +++ b/packages/timeline-state-resolver/src/conductor.ts @@ -44,7 +44,7 @@ import { DeviceOptionsViscaOverIP, DeviceOptionsTriCaster, DeviceOptionsSingularLive, - DeviceOptionsWebSocketTcpClient, + DeviceOptionsWebSocketClient, } from 'timeline-state-resolver-types' import { DoOnTime } from './devices/doOnTime' @@ -1219,7 +1219,7 @@ export type DeviceOptionsAnyInternal = | DeviceOptionsTelemetrics | DeviceOptionsTriCaster | DeviceOptionsViscaOverIP - | DeviceOptionsWebSocketTcpClient + | DeviceOptionsWebSocketClient function removeParentFromState( o: Timeline.TimelineState diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/$schemas/actions.json b/packages/timeline-state-resolver/src/integrations/websocketClient/$schemas/actions.json similarity index 61% rename from packages/timeline-state-resolver/src/integrations/websocketTcpClient/$schemas/actions.json rename to packages/timeline-state-resolver/src/integrations/websocketClient/$schemas/actions.json index e6ef7d762..cf94d3d8f 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/$schemas/actions.json +++ b/packages/timeline-state-resolver/src/integrations/websocketClient/$schemas/actions.json @@ -33,27 +33,6 @@ "required": ["message"], "additionalProperties": false } - }, - { - "id": "sendTcpMessage", - "name": "Send TCP Message", - "destructive": false, - "timeout": 5000, - "payload": { - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "Message to send over TCP" - }, - "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/websocketTcpClient/$schemas/options.json b/packages/timeline-state-resolver/src/integrations/websocketClient/$schemas/options.json similarity index 55% rename from packages/timeline-state-resolver/src/integrations/websocketTcpClient/$schemas/options.json rename to packages/timeline-state-resolver/src/integrations/websocketClient/$schemas/options.json index 82d12ad7d..ee9a2f29f 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/$schemas/options.json +++ b/packages/timeline-state-resolver/src/integrations/websocketClient/$schemas/options.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "WebSocket & TCP Client Options", + "title": "WebSocket Client Options", "type": "object", "properties": { "webSocket": { @@ -20,24 +20,8 @@ } }, "required": ["uri"] - }, - "tcp": { - "type": "object", - "properties": { - "host": { - "type": "string", - "ui:title": "Optional - TCP Host", - "description": "Optional TCP server host to connect to, e.g. '127.0.0.1' - some devices may have a TCP connection too" - }, - "port": { - "type": "integer", - "ui:title": "TCP Port", - "description": "TCP server port to connect to" - } - }, - "required": ["host", "port"] } }, - "required": ["webSocket", "tcp"], + "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..8aa092f5d --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/websocketClient/__tests__/websocketClient.spec.ts @@ -0,0 +1,179 @@ +import { jest } from '@jest/globals' +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 ) + }) + + afterEach(() => { + //Are there something like??: + //mockTime.dispose() + // Or can we just ignore this + }) + + describe('Connections', () => { + test('init', async () => { + expect(MockWebSocketConnection.prototype.connect).toHaveBeenCalled() + }) + + test('terminate', async () => { + await device.terminate() + expect(MockWebSocketConnection.prototype.disconnect).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) + + expect(MockWebSocketConnection.prototype.sendWebSocketMessage).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..45913529d --- /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) + + 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 { + let 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 + } +} \ No newline at end of file diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts b/packages/timeline-state-resolver/src/integrations/websocketClient/index.ts similarity index 54% rename from packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts rename to packages/timeline-state-resolver/src/integrations/websocketClient/index.ts index 0784761b0..fa532125f 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/index.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketClient/index.ts @@ -1,60 +1,53 @@ import { CommandWithContext, Device, DeviceContextAPI } from '../../service/device' -import { ActionExecutionResultCode, DeviceStatus, DeviceType, StatusCode, Timeline, TimelineContentTypeWebSocketTcpClient, TSRTimelineContent, WebSocketTCPClientOptions } from 'timeline-state-resolver-types' -import { WebSocketTcpConnection } from './connection' -import { WebsocketTcpClientActions } from 'timeline-state-resolver-types' +import { ActionExecutionResultCode, DeviceStatus, DeviceType, 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 WebSocketTcpCommand extends CommandWithContext { +export interface WebSocketCommand extends CommandWithContext { command: { - type: TimelineContentTypeWebSocketTcpClient + type: TimelineContentTypeWebSocketClient message: string isBase64Encoded?: boolean } context: string } -export type WebSocketTcpClientDeviceState = Timeline.TimelineState +export type WebSocketClientDeviceState = Timeline.TimelineState - export class WebSocketTcpClientDevice extends Device< - WebSocketTCPClientOptions, - WebSocketTcpClientDeviceState, - WebSocketTcpCommand + export class WebSocketClientDevice extends Device< + WebSocketClientOptions, + WebSocketClientDeviceState, + WebSocketCommand > { // Use ! as the connection will be initialized in init: - private connection!: WebSocketTcpConnection + private connection!: WebSocketConnection constructor(context: DeviceContextAPI) { super(context) } - public async init(options: WebSocketTCPClientOptions): Promise { - this.connection = new WebSocketTcpConnection(options) + public async init(options: WebSocketClientOptions): Promise { + this.connection = new WebSocketConnection(options) await this.connection.connect() return true } readonly actions = { - [WebsocketTcpClientActions.Reconnect]: async (_id: string) => { + [WebsocketClientActions.Reconnect]: async (_id: string) => { await this.connection.connect() return { result: ActionExecutionResultCode.Ok } }, - [WebsocketTcpClientActions.ResetState]: async (_id: string) => { + [WebsocketClientActions.ResetState]: async (_id: string) => { return { result: ActionExecutionResultCode.Ok } }, - [WebsocketTcpClientActions.SendWebSocketMessage]: async (_id: string, payload?: Record) => { + [WebsocketClientActions.SendWebSocketMessage]: async (_id: string, payload?: Record) => { if (!payload?.message) { return { result: ActionExecutionResultCode.Error, response: { key: 'Missing message in payload' } } } await this.sendCommand(payload.message) return { result: ActionExecutionResultCode.Ok } - }, - [WebsocketTcpClientActions.SendTcpMessage]: async (_id: string, payload?: Record) => { - if (!payload?.command) { - return { result: ActionExecutionResultCode.Error, response: { key: 'Missing command in payload' } } - } - await this.sendCommand(payload.command) - return { result: ActionExecutionResultCode.Ok } - }, + } } public get connected(): boolean { @@ -66,13 +59,13 @@ export type WebSocketTcpClientDeviceState = Timeline.TimelineState { + public async sendCommand(sendContext: WebSocketCommand): Promise { if (!sendContext.command) return let message: string | Buffer = sendContext.command.message @@ -139,12 +121,7 @@ export type WebSocketTcpClientDeviceState = Timeline.TimelineState { diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts deleted file mode 100644 index 2d235107c..000000000 --- a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/__tests__/websocketTcpClient.spec.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { jest } from '@jest/globals' -import {} from 'timeline-state-resolver-types/dist/integrations/websocketTcpClient' -import { WebSocketTcpConnection } from '../connection' -import { WebSocketTcpClientDevice, WebSocketTcpCommand } from '../index' -import { - Timeline, - DeviceType, - WebSocketTCPClientOptions, - TimelineContentTypeWebSocketTcpClient, - StatusCode, - TSRTimelineContent, -} from 'timeline-state-resolver-types' -import { MockTime } from '../../../__tests__/mockTime' -import { TimelineContentWebSocketTcpClientAny } from 'timeline-state-resolver-types/src' - -// Mock the WebSocketTcpConnection?? -jest.mock('../connection') - -const MockWebSocketTcpConnection = WebSocketTcpConnection as jest.MockedClass - -describe('WebSocketTcpClientDevice', () => { - const mockTime = new MockTime() - let device: WebSocketTcpClientDevice - - 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: WebSocketTCPClientOptions = { - webSocket: { - uri: 'ws://localhost:8080', - reconnectInterval: 5000, - }, - tcp: { - host: '127.0.0.1', - port: 1234, - bufferEncoding: 'utf8', - }, - } - - device = new WebSocketTcpClientDevice(deviceContext as any) - - // Mock connection methods - MockWebSocketTcpConnection.prototype.connect.mockResolvedValue() - MockWebSocketTcpConnection.prototype.disconnect.mockResolvedValue() - MockWebSocketTcpConnection.prototype.connected.mockReturnValue(true) - MockWebSocketTcpConnection.prototype.sendWebSocketMessage.mockImplementation(() => {}) - MockWebSocketTcpConnection.prototype.sendTcpMessage.mockImplementation(() => {}) - - // Initialize device - await device.init( options ) - }) - - afterEach(() => { - //Are there something like??: - //mockTime.dispose() - // Or can we just ignore this - }) - - describe('Connections', () => { - test('init', async () => { - expect(MockWebSocketTcpConnection.prototype.connect).toHaveBeenCalled() - }) - - test('terminate', async () => { - await device.terminate() - expect(MockWebSocketTcpConnection.prototype.disconnect).toHaveBeenCalled() - }) - - test('connected', () => { - expect(device.connected).toBe(true) - - MockWebSocketTcpConnection.prototype.connected.mockReturnValue(false) - expect(device.connected).toBe(false) - }) - - test('getStatus', () => { - MockWebSocketTcpConnection.prototype.connected.mockReturnValue(true) - expect(device.getStatus()).toEqual({ - statusCode: StatusCode.BAD, - messages: ["No Connection"], - }) - - //@ts-expect-error - is set to private - MockWebSocketTcpConnection.prototype.isTcpConnected = true - //@ts-expect-error - is set to private - MockWebSocketTcpConnection.prototype.isWsConnected = true - jest.spyOn(WebSocketTcpConnection.prototype, 'connectionStatus').mockReturnValue({ - statusCode: StatusCode.GOOD, - messages: ["WS Connected", "TCP Connected"], - }) - - //@ts-expect-error - is set to private - MockWebSocketTcpConnection.prototype.isTcpConnected = false - //@ts-expect-error - is set to private - MockWebSocketTcpConnection.prototype.isWsConnected = true - jest.spyOn(WebSocketTcpConnection.prototype, 'connectionStatus').mockReturnValue({ - statusCode: StatusCode.BAD, - messages: ["WS DisConnected", "TCP Connected"], - }) - - }) - }) - - describe('Timeline', () => { - test('convertTimelineStateToDeviceState', () => { - const timelineState: Timeline.TimelineState = createTimelineState( - createWsCommandObject('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(createWsCommandObject('layer1', 'old ws state')) - const newState = createTimelineState(createWsCommandObject('layer1', 'new test ws message state')) - - const commands = device.diffStates(oldState, newState) - - - expect(commands).toHaveLength(1) - expect(commands[0].command.type).toBe(TimelineContentTypeWebSocketTcpClient.WEBSOCKET_MESSAGE) - expect(commands[0].command.message).toBe('new test ws message state') - }) - - test('diffStates with TCP message command', () => { - const oldState = createTimelineState(createWsCommandObject('layer1', 'old tcp tate')) - const newState = createTimelineState(createTcpCommandObject('layer1', 'new test tcp message state')) - - const commands = device.diffStates(oldState, newState) - - expect(commands).toHaveLength(1) - expect(commands[0].command.type).toBe(TimelineContentTypeWebSocketTcpClient.TCP_MESSAGE) - expect(commands[0].command.message).toBe('new test tcp message state') - }) - - test('sendCommand with WebSocket message', async () => { - const command: WebSocketTcpCommand = { - context: 'context', - timelineObjId: 'obj1', - command: { - type: TimelineContentTypeWebSocketTcpClient.WEBSOCKET_MESSAGE, - message: 'test ws message', - }, - } - - await device.sendCommand(command) - - expect(MockWebSocketTcpConnection.prototype.sendWebSocketMessage).toHaveBeenCalledWith('test ws message') - }) - - test('sendCommand with TCP command', async () => { - const message: WebSocketTcpCommand = { - context: 'context', - timelineObjId: 'obj1', - command: { - type: TimelineContentTypeWebSocketTcpClient.TCP_MESSAGE, - message: 'test tcp message', - }, - } - - await device.sendCommand(message) - - expect(MockWebSocketTcpConnection.prototype.sendTcpMessage).toHaveBeenCalledWith('test tcp 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 createWsCommandObject( - layerId: string, - message: string -): Record { - return { - [`tcp_${layerId}`]: { - id: `tcp_${layerId}`, - content: { - deviceType: DeviceType.WEBSOCKET_TCP_CLIENT, - type: TimelineContentTypeWebSocketTcpClient.WEBSOCKET_MESSAGE, - message: message, // Changed from 'command' to 'message' to match the interface - }, - }, - } -} - -function createTcpCommandObject( - layerId: string, - message: string -): Record { - return { - [`tcp_${layerId}`]: { - id: `tcp_${layerId}`, - content: { - deviceType: DeviceType.WEBSOCKET_TCP_CLIENT, - type: TimelineContentTypeWebSocketTcpClient.TCP_MESSAGE, - message: message, // Changed from 'command' to 'message' to match the interface - }, - }, - } -} diff --git a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts b/packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts deleted file mode 100644 index 4a5a3b2e5..000000000 --- a/packages/timeline-state-resolver/src/integrations/websocketTcpClient/connection.ts +++ /dev/null @@ -1,134 +0,0 @@ -import * as WebSocket from 'ws' -import { Socket } from 'net' -import { DeviceStatus, StatusCode, WebSocketTCPClientOptions } from 'timeline-state-resolver-types' - -export class WebSocketTcpConnection { - private ws?: WebSocket - private tcp?: Socket - private isWsConnected = false - private isTcpConnected = false - private readonly options: WebSocketTCPClientOptions - - constructor(options: WebSocketTCPClientOptions) { - this.options = options - } - - async connect(): Promise { - try { - // WebSocket connection - if (this.options.webSocket?.uri) { - this.ws = new WebSocket(this.options.webSocket.uri) - - 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 - }) - } - - // Optional TCP connection - if (this.options.tcp?.host && this.options.tcp?.port) { - this.tcp = new Socket() - - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('TCP connection timeout')) - }, 5000) - - this.tcp?.connect({ - host: this.options.tcp?.host || '', - port: this.options.tcp?.port || 0 - }, () => { - clearTimeout(timeout) - resolve() - }) - - this.tcp?.on('error', (error) => { - this.isTcpConnected = false - clearTimeout(timeout) - reject(error) - }) - - this.tcp?.on('close', () => { - this.isTcpConnected = false - }) - }) - } - - this.isTcpConnected = true - } catch (error) { - this.isTcpConnected = false - throw error - } - } - - connected(): boolean { - // Check if both WebSocket and TCP connections are active - // And only use the isTcpConnected flag if the TCP connection is defined - const isConnected = this.isWsConnected && (this.tcp ? this.isTcpConnected : true) - return isConnected - } - - connectionStatus(): Omit { - // Check if both WebSocket and TCP connections are active - // And only use the isTcpConnected flag if the TCP connection is defined - const isConnected = this.isWsConnected && (this.tcp ? this.isTcpConnected : true) - let messages: string[] = [] - messages.push(this.isWsConnected ? 'WS Connected' : 'WS Disconnected') - if (this.tcp) { - messages.push(this.isTcpConnected ? 'TCP Connected' : 'TCP Disconnected') - } - return { - statusCode: isConnected ? 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) - } - - sendTcpMessage(message: string | Buffer): void { - if (!this.tcp) { - this.isTcpConnected = false - throw new Error('TCP not connected') - } - this.tcp.write(message) - } - - async disconnect(): Promise { - if (this.ws) { - this.ws.close() - this.ws = undefined - } - - if (this.tcp) { - this.tcp.destroy() - this.tcp = undefined - } - - this.isWsConnected = false - this.isTcpConnected = false - } -} \ No newline at end of file diff --git a/packages/timeline-state-resolver/src/manifest.ts b/packages/timeline-state-resolver/src/manifest.ts index 85f5f4d9c..e117433cc 100644 --- a/packages/timeline-state-resolver/src/manifest.ts +++ b/packages/timeline-state-resolver/src/manifest.ts @@ -56,8 +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 WebSocketTCPClientOptions = require('./$schemas/generated/websocketTcpClient/options.json') -import WebSocketTCPClientActions = require('./$schemas/generated/websocketTcpClient/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' @@ -216,10 +216,10 @@ export const manifest: TSRManifest = { configSchema: JSON.stringify(VMixOptions), mappingsSchemas: stringifyMappingSchema(VMixMappings), }, - [DeviceType.WEBSOCKET_TCP_CLIENT]: { - displayName: generateTranslation('Websocket+TCP Client'), - actions: WebSocketTCPClientActions.actions.map(stringifyActionSchema), - configSchema: JSON.stringify(WebSocketTCPClientOptions), + [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 be7dc1738..aab2968d0 100644 --- a/packages/timeline-state-resolver/src/service/ConnectionManager.ts +++ b/packages/timeline-state-resolver/src/service/ConnectionManager.ts @@ -436,7 +436,7 @@ function createContainer( case DeviceType.TCPSEND: case DeviceType.TRICASTER: case DeviceType.VISCA_OVER_IP: - case DeviceType.WEBSOCKET_TCP_CLIENT: + case DeviceType.WEBSOCKET_CLIENT: case DeviceType.QUANTEL: { ensureIsImplementedAsService(deviceOptions.type) diff --git a/packages/timeline-state-resolver/src/service/devices.ts b/packages/timeline-state-resolver/src/service/devices.ts index 9e1a58cf8..a46fc1a88 100644 --- a/packages/timeline-state-resolver/src/service/devices.ts +++ b/packages/timeline-state-resolver/src/service/devices.ts @@ -19,7 +19,7 @@ import { TelemetricsDevice } from '../integrations/telemetrics' import { TriCasterDevice } from '../integrations/tricaster' import { SingularLiveDevice } from '../integrations/singularLive' import { MultiOSCMessageDevice } from '../integrations/multiOsc' -import { WebSocketTcpClientDevice } from '../integrations/websocketTcpClient' +import { WebSocketClientDevice } from '../integrations/websocketClient' export interface DeviceEntry { deviceClass: new (context: DeviceContextAPI) => Device @@ -48,7 +48,7 @@ export type ImplementedServiceDeviceTypes = | DeviceType.TRICASTER | DeviceType.QUANTEL | DeviceType.VISCA_OVER_IP - | DeviceType.WEBSOCKET_TCP_CLIENT + | DeviceType.WEBSOCKET_CLIENT // TODO - move all device implementations here and remove the old Device classes export const DevicesDict: Record = { @@ -166,10 +166,10 @@ export const DevicesDict: Record = { deviceName: (deviceId: string) => 'VISCAOverIP ' + deviceId, executionMode: () => 'sequential', }, - [DeviceType.WEBSOCKET_TCP_CLIENT]: { - deviceClass: WebSocketTcpClientDevice, + [DeviceType.WEBSOCKET_CLIENT]: { + deviceClass: WebSocketClientDevice, canConnect: true, - deviceName: (deviceId: string) => 'WebSocket TCP Client ' + deviceId, + deviceName: (deviceId: string) => 'WebSocket Client ' + deviceId, executionMode: () => 'sequential', } } From 71c8bcd29166e6772f976c8f43d34e376cf6bc92 Mon Sep 17 00:00:00 2001 From: olzzon Date: Fri, 28 Feb 2025 09:38:48 +0100 Subject: [PATCH 25/34] fix: missing linting --- .../websocketClient/$schemas/actions.json | 72 +++++----- .../websocketClient/$schemas/options.json | 54 ++++---- .../__tests__/websocketClient.spec.ts | 17 +-- .../websocketClient/connection.ts | 128 +++++++++--------- .../src/integrations/websocketClient/index.ts | 54 ++++---- .../timeline-state-resolver/src/manifest.ts | 2 +- .../src/service/device.ts | 12 +- .../src/service/devices.ts | 2 +- 8 files changed, 173 insertions(+), 168 deletions(-) diff --git a/packages/timeline-state-resolver/src/integrations/websocketClient/$schemas/actions.json b/packages/timeline-state-resolver/src/integrations/websocketClient/$schemas/actions.json index cf94d3d8f..2b6547efa 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketClient/$schemas/actions.json +++ b/packages/timeline-state-resolver/src/integrations/websocketClient/$schemas/actions.json @@ -1,38 +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" - } + "$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" }, - "required": ["message"], - "additionalProperties": false - } + "queueId": { + "type": "string", + "description": "Optional queue ID for ordered message handling" + } + }, + "required": [ + "message" + ], + "additionalProperties": false } - ] - } \ No newline at end of file + } + ] +} \ 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 index ee9a2f29f..b1b47175f 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketClient/$schemas/options.json +++ b/packages/timeline-state-resolver/src/integrations/websocketClient/$schemas/options.json @@ -1,27 +1,31 @@ { - "$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 - } + "$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'" }, - "required": ["uri"] - } - }, - "required": ["webSocket"], - "additionalProperties": false - } \ No newline at end of file + "reconnectInterval": { + "type": "integer", + "ui:title": "Reconnect Interval", + "description": "Interval between reconnection attempts in milliseconds", + "default": 5000 + } + }, + "required": [ + "uri" + ] + } + }, + "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 index 8aa092f5d..60e8a6adc 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketClient/__tests__/websocketClient.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketClient/__tests__/websocketClient.spec.ts @@ -1,4 +1,3 @@ -import { jest } from '@jest/globals' import {} from 'timeline-state-resolver-types/dist/integrations/websocketClient' import { WebSocketConnection } from '../connection' import { WebSocketClientDevice, WebSocketCommand } from '../index' @@ -57,10 +56,10 @@ describe('WebSocketClientDevice', () => { MockWebSocketConnection.prototype.connect.mockResolvedValue() MockWebSocketConnection.prototype.disconnect.mockResolvedValue() MockWebSocketConnection.prototype.connected.mockReturnValue(true) - MockWebSocketConnection.prototype.sendWebSocketMessage.mockImplementation(() => {}) + MockWebSocketConnection.prototype.sendWebSocketMessage.mockImplementation() // Initialize device - await device.init( options ) + await device.init(options) }) afterEach(() => { @@ -81,7 +80,7 @@ describe('WebSocketClientDevice', () => { test('connected', () => { expect(device.connected).toBe(true) - + MockWebSocketConnection.prototype.connected.mockReturnValue(false) expect(device.connected).toBe(false) }) @@ -90,23 +89,22 @@ describe('WebSocketClientDevice', () => { MockWebSocketConnection.prototype.connected.mockReturnValue(true) expect(device.getStatus()).toEqual({ statusCode: StatusCode.BAD, - messages: ["No Connection"], + 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"], + 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"], + messages: ['WS DisConnected'], }) - }) }) @@ -125,7 +123,6 @@ describe('WebSocketClientDevice', () => { 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') @@ -145,7 +142,6 @@ describe('WebSocketClientDevice', () => { expect(MockWebSocketConnection.prototype.sendWebSocketMessage).toHaveBeenCalledWith('test ws message') }) - }) }) @@ -176,4 +172,3 @@ function createCommandObject( }, } } - diff --git a/packages/timeline-state-resolver/src/integrations/websocketClient/connection.ts b/packages/timeline-state-resolver/src/integrations/websocketClient/connection.ts index 45913529d..84c2d85d2 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketClient/connection.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketClient/connection.ts @@ -2,77 +2,77 @@ 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 + private ws?: WebSocket + private isWsConnected = false + private readonly options: WebSocketClientOptions - constructor(options: WebSocketClientOptions) { - this.options = options - } + 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) - - 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) + async connect(): Promise { + try { + // WebSocket connection + if (this.options.webSocket?.uri) { + this.ws = new WebSocket(this.options.webSocket.uri) - this.ws.on('open', () => { - clearTimeout(timeout) - this.isWsConnected = true - resolve() - }) + await new Promise((resolve, reject) => { + if (!this.ws) return reject(new Error('WebSocket not initialized')) - this.ws.on('error', (error) => { - clearTimeout(timeout) - reject(error) - }) - }) + const timeout = setTimeout(() => { + reject(new Error('WebSocket connection timeout')) + }, this.options.webSocket?.reconnectInterval || 5000) - this.ws.on('close', () => { - this.isWsConnected = false - }) - } - } catch (error) { - this.isWsConnected = false - throw error - } - } + this.ws.on('open', () => { + clearTimeout(timeout) + this.isWsConnected = true + resolve() + }) - connected(): boolean { - return this.isWsConnected ? true : false - } + this.ws.on('error', (error) => { + clearTimeout(timeout) + reject(error) + }) + }) - connectionStatus(): Omit { - let 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 - } - } + this.ws.on('close', () => { + this.isWsConnected = false + }) + } + } catch (error) { + this.isWsConnected = false + throw error + } + } - sendWebSocketMessage(message: string | Buffer): void { - if (!this.ws) { - this.isWsConnected = false - throw new Error('WebSocket not connected') - } - this.ws.send(message) - } + connected(): boolean { + return this.isWsConnected ? true : false + } - async disconnect(): Promise { - if (this.ws) { - this.ws.close() - this.ws = undefined - } + 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, + } + } - this.isWsConnected = false - } -} \ No newline at end of file + 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 index fa532125f..84080ef70 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketClient/index.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketClient/index.ts @@ -1,9 +1,17 @@ import { CommandWithContext, Device, DeviceContextAPI } from '../../service/device' -import { ActionExecutionResultCode, DeviceStatus, DeviceType, StatusCode, Timeline, TimelineContentTypeWebSocketClient, TSRTimelineContent, WebSocketClientOptions } from 'timeline-state-resolver-types' +import { + ActionExecutionResultCode, + DeviceStatus, + DeviceType, + 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: { @@ -12,10 +20,10 @@ export interface WebSocketCommand extends CommandWithContext { isBase64Encoded?: boolean } context: string - } +} export type WebSocketClientDeviceState = Timeline.TimelineState - export class WebSocketClientDevice extends Device< +export class WebSocketClientDevice extends Device< WebSocketClientOptions, WebSocketClientDeviceState, WebSocketCommand @@ -26,12 +34,12 @@ export type WebSocketClientDeviceState = Timeline.TimelineState) { super(context) } - - public async init(options: WebSocketClientOptions): Promise { + + public async init(options: WebSocketClientOptions): Promise { this.connection = new WebSocketConnection(options) - await this.connection.connect() - return true - } + await this.connection.connect() + return true + } readonly actions = { [WebsocketClientActions.Reconnect]: async (_id: string) => { @@ -47,20 +55,18 @@ export type WebSocketClientDeviceState = Timeline.TimelineState { + public getStatus(): Omit { return this.connection?.connectionStatus() ?? { statusCode: StatusCode.BAD, messages: ['No Connection'] } } - public convertTimelineStateToDeviceState( - state: WebSocketClientDeviceState - ): WebSocketClientDeviceState { + 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: @@ -78,50 +84,48 @@ export type WebSocketClientDeviceState = Timeline.TimelineState + >(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)) { - //} else if ( isEqual(oldState.layers[layerName].content, timelineObject.content) ) { + //} else if ( isEqual(oldState.layers[layerName].content, timelineObject.content) ) { changeType = 'changed' } else { continue // no changes } - - if (timelineObject.content.type === TimelineContentTypeWebSocketClient.WEBSOCKET_MESSAGE ) { + if (timelineObject.content.type === TimelineContentTypeWebSocketClient.WEBSOCKET_MESSAGE) { commands.push({ command: { type: TimelineContentTypeWebSocketClient.WEBSOCKET_MESSAGE, message: timelineObject.content.message, - isBase64Encoded: timelineObject.content.isBase64Encoded + isBase64Encoded: timelineObject.content.isBase64Encoded, }, 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 - + if (sendContext.command.isBase64Encoded) { // convert base64 to binary message = Buffer.from(message, 'base64') } - await this.connection.sendWebSocketMessage(message) + this.connection.sendWebSocketMessage(message) } public async terminate(): Promise { diff --git a/packages/timeline-state-resolver/src/manifest.ts b/packages/timeline-state-resolver/src/manifest.ts index e117433cc..d46533c77 100644 --- a/packages/timeline-state-resolver/src/manifest.ts +++ b/packages/timeline-state-resolver/src/manifest.ts @@ -221,6 +221,6 @@ export const manifest: TSRManifest = { actions: WebSocketClientActions.actions.map(stringifyActionSchema), configSchema: JSON.stringify(WebSocketClientOptions), mappingsSchemas: {}, - } + }, }, } diff --git a/packages/timeline-state-resolver/src/service/device.ts b/packages/timeline-state-resolver/src/service/device.ts index befb89cf6..d156b16ed 100644 --- a/packages/timeline-state-resolver/src/service/device.ts +++ b/packages/timeline-state-resolver/src/service/device.ts @@ -21,7 +21,7 @@ type CommandContext = any */ export type CommandWithContext = { /** Device specific command (to be defined by the device itself) */ - command: any + 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.) @@ -94,10 +94,10 @@ export interface BaseDeviceAPI * in time on the timeline and converts it into a "device state" that * describes how the device should be according to the timeline state. * This is optional, and intended to simplify diffing logic in - * The order of TSR is: - * - Timeline Object in Timeline State -> - * - Device State (`convertTimelineStateToDeviceState()`) -> - * - Planned Device Commands (`difStates()`) -> + * 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 @@ -110,7 +110,7 @@ export interface BaseDeviceAPI /** * 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()`. */ diff --git a/packages/timeline-state-resolver/src/service/devices.ts b/packages/timeline-state-resolver/src/service/devices.ts index a46fc1a88..b28d575d9 100644 --- a/packages/timeline-state-resolver/src/service/devices.ts +++ b/packages/timeline-state-resolver/src/service/devices.ts @@ -171,5 +171,5 @@ export const DevicesDict: Record = { canConnect: true, deviceName: (deviceId: string) => 'WebSocket Client ' + deviceId, executionMode: () => 'sequential', - } + }, } From e0d035a8b4ed0755390a33cfd2e91f243ba34ac4 Mon Sep 17 00:00:00 2001 From: olzzon Date: Fri, 28 Feb 2025 10:01:52 +0100 Subject: [PATCH 26/34] fix: use undefined in initialization instead of ! --- .../src/integrations/websocketClient/index.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/timeline-state-resolver/src/integrations/websocketClient/index.ts b/packages/timeline-state-resolver/src/integrations/websocketClient/index.ts index 84080ef70..00f319f63 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketClient/index.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketClient/index.ts @@ -29,11 +29,7 @@ export class WebSocketClientDevice extends Device< WebSocketCommand > { // Use ! as the connection will be initialized in init: - private connection!: WebSocketConnection - - constructor(context: DeviceContextAPI) { - super(context) - } + private connection: WebSocketConnection | undefined public async init(options: WebSocketClientOptions): Promise { this.connection = new WebSocketConnection(options) @@ -43,7 +39,7 @@ export class WebSocketClientDevice extends Device< readonly actions = { [WebsocketClientActions.Reconnect]: async (_id: string) => { - await this.connection.connect() + await this.connection?.connect() return { result: ActionExecutionResultCode.Ok } }, [WebsocketClientActions.ResetState]: async (_id: string) => { @@ -125,11 +121,11 @@ export class WebSocketClientDevice extends Device< // convert base64 to binary message = Buffer.from(message, 'base64') } - this.connection.sendWebSocketMessage(message) + this.connection?.sendWebSocketMessage(message) } public async terminate(): Promise { - await this.connection.disconnect() + await this.connection?.disconnect() // Perform any cleanup if needed } } From 02711c88e40a520edd9008a9a41008f822a56e06 Mon Sep 17 00:00:00 2001 From: olzzon Date: Fri, 28 Feb 2025 10:44:57 +0100 Subject: [PATCH 27/34] fix: websocketclient action type --- .../src/integrations/websocketClient/index.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/timeline-state-resolver/src/integrations/websocketClient/index.ts b/packages/timeline-state-resolver/src/integrations/websocketClient/index.ts index 00f319f63..004ad5bd1 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketClient/index.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketClient/index.ts @@ -1,8 +1,9 @@ -import { CommandWithContext, Device, DeviceContextAPI } from '../../service/device' +import { CommandWithContext, Device } from '../../service/device' import { ActionExecutionResultCode, DeviceStatus, DeviceType, + SendWebSocketMessagePayload, StatusCode, Timeline, TimelineContentTypeWebSocketClient, @@ -45,11 +46,16 @@ export class WebSocketClientDevice extends Device< [WebsocketClientActions.ResetState]: async (_id: string) => { return { result: ActionExecutionResultCode.Ok } }, - [WebsocketClientActions.SendWebSocketMessage]: async (_id: string, payload?: Record) => { + [WebsocketClientActions.SendWebSocketMessage]: async ( + _id: string, + payload?: Record + ) => { if (!payload?.message) { return { result: ActionExecutionResultCode.Error, response: { key: 'Missing message in payload' } } } - await this.sendCommand(payload.message) + for (const [cmd] of Object.entries(payload)) { + this.connection?.sendWebSocketMessage(cmd) + } return { result: ActionExecutionResultCode.Ok } }, } From bce3d1a1a9d2c1d97744eb45265772b5e26980a8 Mon Sep 17 00:00:00 2001 From: olzzon Date: Fri, 28 Feb 2025 11:33:58 +0100 Subject: [PATCH 28/34] fix: websocketClient diffstates oldState could be undefined --- .../src/integrations/websocketClient/index.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/timeline-state-resolver/src/integrations/websocketClient/index.ts b/packages/timeline-state-resolver/src/integrations/websocketClient/index.ts index 004ad5bd1..6587351d6 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketClient/index.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketClient/index.ts @@ -81,7 +81,10 @@ export class WebSocketClientDevice extends Device< } // ** Calculate Diffs of state and create the commands - public diffStates(oldState: WebSocketClientDeviceState, newState: WebSocketClientDeviceState): WebSocketCommand[] { + 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() ). @@ -94,10 +97,9 @@ export class WebSocketClientDevice extends Device< // We should send the command whenever the timeline object content has been ADDED or CHANGED let changeType = 'N/A' - if (!oldState.layers[layerName]) { + if (!oldState?.layers[layerName]) { changeType = 'added' - } else if (JSON.stringify(oldState.layers[layerName].content) !== JSON.stringify(timelineObject.content)) { - //} else if ( isEqual(oldState.layers[layerName].content, timelineObject.content) ) { + } else if (JSON.stringify(oldState?.layers[layerName].content) !== JSON.stringify(timelineObject.content)) { changeType = 'changed' } else { continue // no changes From a6d66a66389441bd9950b93005095408378c91b2 Mon Sep 17 00:00:00 2001 From: olzzon Date: Fri, 28 Feb 2025 12:11:20 +0100 Subject: [PATCH 29/34] fix: websocketClient test should spy on function --- .../websocketClient/__tests__/websocketClient.spec.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 index 60e8a6adc..a33bf7831 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketClient/__tests__/websocketClient.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketClient/__tests__/websocketClient.spec.ts @@ -70,12 +70,14 @@ describe('WebSocketClientDevice', () => { describe('Connections', () => { test('init', async () => { - expect(MockWebSocketConnection.prototype.connect).toHaveBeenCalled() + const connectSpy = jest.spyOn(MockWebSocketConnection.prototype, 'connect') + expect(connectSpy).toHaveBeenCalled() }) test('terminate', async () => { await device.terminate() - expect(MockWebSocketConnection.prototype.disconnect).toHaveBeenCalled() + const disConnectSpy = jest.spyOn(MockWebSocketConnection.prototype, 'disconnect') + expect(disConnectSpy).toHaveBeenCalled() }) test('connected', () => { @@ -140,7 +142,8 @@ describe('WebSocketClientDevice', () => { await device.sendCommand(command) - expect(MockWebSocketConnection.prototype.sendWebSocketMessage).toHaveBeenCalledWith('test ws message') + const sendMessageSpy = jest.spyOn(MockWebSocketConnection.prototype, 'sendWebSocketMessage') + expect(sendMessageSpy).toHaveBeenCalledWith('test ws message') }) }) }) From b5fee17b95a4173b9caf7a874c424b2221cc5b3f Mon Sep 17 00:00:00 2001 From: olzzon Date: Fri, 28 Feb 2025 12:42:19 +0100 Subject: [PATCH 30/34] fix: update comment with missing details --- packages/timeline-state-resolver/src/service/device.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/timeline-state-resolver/src/service/device.ts b/packages/timeline-state-resolver/src/service/device.ts index d156b16ed..88c92c8f0 100644 --- a/packages/timeline-state-resolver/src/service/device.ts +++ b/packages/timeline-state-resolver/src/service/device.ts @@ -93,7 +93,7 @@ export interface BaseDeviceAPI * This method takes in a Timeline State that describes a point * in time on the timeline and converts it into a "device state" that * describes how the device should be according to the timeline state. - * This is optional, and intended to simplify diffing logic in + * 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()`) -> From fc514a3384309016400dd8ab48f5591ad896f3d8 Mon Sep 17 00:00:00 2001 From: olzzon Date: Fri, 28 Feb 2025 14:27:40 +0100 Subject: [PATCH 31/34] feat: websocketclient move buffer encoding to options --- .../src/generated/websocketClient.ts | 13 +++++++++- .../src/integrations/websocketClient.ts | 2 -- .../websocketClient/$schemas/options.json | 24 +++++++++++++++++-- .../websocketClient/connection.ts | 2 +- .../src/integrations/websocketClient/index.ts | 8 +------ 5 files changed, 36 insertions(+), 13 deletions(-) diff --git a/packages/timeline-state-resolver-types/src/generated/websocketClient.ts b/packages/timeline-state-resolver-types/src/generated/websocketClient.ts index caaaa06ca..fc1668f48 100644 --- a/packages/timeline-state-resolver-types/src/generated/websocketClient.ts +++ b/packages/timeline-state-resolver-types/src/generated/websocketClient.ts @@ -16,8 +16,19 @@ export interface WebSocketClientOptions { * Interval between reconnection attempts in milliseconds */ reconnectInterval?: number - [k: string]: unknown } + bufferEncoding: + | 'ascii' + | 'utf8' + | 'utf-8' + | 'utf16le' + | 'ucs2' + | 'ucs-2' + | 'base64' + | 'base64url' + | 'latin1' + | 'binary' + | 'hex' } export type SomeMappingWebsocketClient = Record diff --git a/packages/timeline-state-resolver-types/src/integrations/websocketClient.ts b/packages/timeline-state-resolver-types/src/integrations/websocketClient.ts index 684f33ac1..60642ab4a 100644 --- a/packages/timeline-state-resolver-types/src/integrations/websocketClient.ts +++ b/packages/timeline-state-resolver-types/src/integrations/websocketClient.ts @@ -13,8 +13,6 @@ export interface TimelineContentWebSocketMessage extends TimelineContentWebSocke type: TimelineContentTypeWebSocketClient.WEBSOCKET_MESSAGE /** Stringified data to send over Websocket connection */ message: string - /** If message contains stringified Base64 binary data or UTF-8 encoded string */ - isBase64Encoded?: boolean } export type TimelineContentWebSocketClientAny = TimelineContentWebSocketMessage diff --git a/packages/timeline-state-resolver/src/integrations/websocketClient/$schemas/options.json b/packages/timeline-state-resolver/src/integrations/websocketClient/$schemas/options.json index b1b47175f..68a7b9aa0 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketClient/$schemas/options.json +++ b/packages/timeline-state-resolver/src/integrations/websocketClient/$schemas/options.json @@ -21,11 +21,31 @@ }, "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" + "webSocket", + "bufferEncoding" ], "additionalProperties": false } \ No newline at end of file diff --git a/packages/timeline-state-resolver/src/integrations/websocketClient/connection.ts b/packages/timeline-state-resolver/src/integrations/websocketClient/connection.ts index 84c2d85d2..b5385523e 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketClient/connection.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketClient/connection.ts @@ -14,7 +14,7 @@ export class WebSocketConnection { try { // WebSocket connection if (this.options.webSocket?.uri) { - this.ws = new WebSocket(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')) diff --git a/packages/timeline-state-resolver/src/integrations/websocketClient/index.ts b/packages/timeline-state-resolver/src/integrations/websocketClient/index.ts index 6587351d6..e44c1c815 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketClient/index.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketClient/index.ts @@ -18,7 +18,6 @@ export interface WebSocketCommand extends CommandWithContext { command: { type: TimelineContentTypeWebSocketClient message: string - isBase64Encoded?: boolean } context: string } @@ -109,8 +108,7 @@ export class WebSocketClientDevice extends Device< commands.push({ command: { type: TimelineContentTypeWebSocketClient.WEBSOCKET_MESSAGE, - message: timelineObject.content.message, - isBase64Encoded: timelineObject.content.isBase64Encoded, + message: timelineObject.content.message }, context: `${changeType} on layer "${layerName}"`, timelineObjId: timelineObject.id, @@ -125,10 +123,6 @@ export class WebSocketClientDevice extends Device< if (!sendContext.command) return let message: string | Buffer = sendContext.command.message - if (sendContext.command.isBase64Encoded) { - // convert base64 to binary - message = Buffer.from(message, 'base64') - } this.connection?.sendWebSocketMessage(message) } From f87754256d54c27dbbd072f1fc2fce3bce1a65c6 Mon Sep 17 00:00:00 2001 From: olzzon Date: Fri, 28 Feb 2025 14:29:10 +0100 Subject: [PATCH 32/34] fix: websocketclient bufferEnc option should be optional --- .../src/generated/websocketClient.ts | 2 +- .../src/integrations/websocketClient/$schemas/options.json | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/timeline-state-resolver-types/src/generated/websocketClient.ts b/packages/timeline-state-resolver-types/src/generated/websocketClient.ts index fc1668f48..825371ab6 100644 --- a/packages/timeline-state-resolver-types/src/generated/websocketClient.ts +++ b/packages/timeline-state-resolver-types/src/generated/websocketClient.ts @@ -17,7 +17,7 @@ export interface WebSocketClientOptions { */ reconnectInterval?: number } - bufferEncoding: + bufferEncoding?: | 'ascii' | 'utf8' | 'utf-8' diff --git a/packages/timeline-state-resolver/src/integrations/websocketClient/$schemas/options.json b/packages/timeline-state-resolver/src/integrations/websocketClient/$schemas/options.json index 68a7b9aa0..ca786d9ac 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketClient/$schemas/options.json +++ b/packages/timeline-state-resolver/src/integrations/websocketClient/$schemas/options.json @@ -44,8 +44,7 @@ } }, "required": [ - "webSocket", - "bufferEncoding" + "webSocket" ], "additionalProperties": false } \ No newline at end of file From e420459bdfdfff2c6518f14e132a1379ec38d161 Mon Sep 17 00:00:00 2001 From: olzzon Date: Fri, 28 Feb 2025 14:45:12 +0100 Subject: [PATCH 33/34] fix: websocketClient test - remove unused afterEach() --- .../websocketClient/__tests__/websocketClient.spec.ts | 6 ------ 1 file changed, 6 deletions(-) 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 index a33bf7831..dee41965b 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketClient/__tests__/websocketClient.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketClient/__tests__/websocketClient.spec.ts @@ -62,12 +62,6 @@ describe('WebSocketClientDevice', () => { await device.init(options) }) - afterEach(() => { - //Are there something like??: - //mockTime.dispose() - // Or can we just ignore this - }) - describe('Connections', () => { test('init', async () => { const connectSpy = jest.spyOn(MockWebSocketConnection.prototype, 'connect') From 40dfc34058fc62255da6661566ca74e7ea5f35d5 Mon Sep 17 00:00:00 2001 From: olzzon Date: Fri, 28 Feb 2025 14:47:41 +0100 Subject: [PATCH 34/34] fix: websocketclient tests - reorder terminate spy --- .../websocketClient/__tests__/websocketClient.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index dee41965b..bacaf07dd 100644 --- a/packages/timeline-state-resolver/src/integrations/websocketClient/__tests__/websocketClient.spec.ts +++ b/packages/timeline-state-resolver/src/integrations/websocketClient/__tests__/websocketClient.spec.ts @@ -69,8 +69,8 @@ describe('WebSocketClientDevice', () => { }) test('terminate', async () => { - await device.terminate() const disConnectSpy = jest.spyOn(MockWebSocketConnection.prototype, 'disconnect') + await device.terminate() expect(disConnectSpy).toHaveBeenCalled() })