From 0bb71d63bb38bb81df06b91b4b3cff2b5d97611d Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Sun, 3 May 2026 23:49:02 -0400 Subject: [PATCH] Add core MCP server implementation and Ross Talk protocol handler: ross-talk.ts --- src/ross-talk.ts | 242 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 src/ross-talk.ts diff --git a/src/ross-talk.ts b/src/ross-talk.ts new file mode 100644 index 0000000..cc4a1ab --- /dev/null +++ b/src/ross-talk.ts @@ -0,0 +1,242 @@ +import WebSocket from 'ws'; +import { parseString as parseXML } from 'xml2js'; +import { RossUltrixState, RossTalkConfig, PanelStatus, InputStatus } from './types.js'; + +export class RossTalkClient { + private ws: WebSocket | null = null; + private config: RossTalkConfig; + private isConnected = false; + private reconnectTimer: NodeJS.Timeout | null = null; + private heartbeatTimer: NodeJS.Timeout | null = null; + private onStateChange?: (state: Partial) => void; + + constructor(config: RossTalkConfig) { + this.config = config; + this.onStateChange = config.onStateChange; + } + + getHost(): string { + return this.config.host; + } + + getPort(): number { + return this.config.port; + } + + async connect(): Promise { + if (this.isConnected) { + return; + } + + return new Promise((resolve, reject) => { + const wsUrl = `ws://${this.config.host}:${this.config.port}/rosstalk`; + console.error(`Connecting to Ross Talk at ${wsUrl}`); + + this.ws = new WebSocket(wsUrl, { + headers: { + 'Sec-WebSocket-Protocol': 'rosstalk' + } + }); + + const timeout = setTimeout(() => { + reject(new Error('Connection timeout')); + }, 10000); + + this.ws.on('open', () => { + clearTimeout(timeout); + this.isConnected = true; + console.error('Connected to Ross Ultrix via Ross Talk'); + this.startHeartbeat(); + this.onStateChange?.({ connected: true }); + resolve(); + }); + + this.ws.on('error', (error) => { + clearTimeout(timeout); + console.error('Ross Talk connection error:', error); + reject(error); + }); + + this.ws.on('close', () => { + this.isConnected = false; + this.onStateChange?.({ connected: false }); + console.error('Ross Talk connection closed'); + this.scheduleReconnect(); + }); + + this.ws.on('message', (data) => { + this.handleMessage(data.toString()); + }); + }); + } + + async disconnect(): Promise { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + if (this.heartbeatTimer) { + clearTimeout(this.heartbeatTimer); + this.heartbeatTimer = null; + } + + if (this.ws) { + this.ws.close(); + this.ws = null; + } + + this.isConnected = false; + this.onStateChange?.({ connected: false }); + } + + private scheduleReconnect(): void { + if (this.reconnectTimer) return; + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + if (!this.isConnected) { + console.error('Attempting to reconnect to Ross Talk...'); + this.connect().catch(console.error); + } + }, 5000); + } + + private startHeartbeat(): void { + this.heartbeatTimer = setInterval(() => { + if (this.isConnected && this.ws?.readyState === WebSocket.OPEN) { + this.sendCommand(''); + } + }, 30000); + } + + private handleMessage(message: string): void { + console.error('Received message:', message); + + // Parse XML message from Ross Talk + parseXML(message, { explicitArray: false }, (err, result) => { + if (err) { + console.error('Failed to parse Ross Talk message:', err); + return; + } + + this.processRossTalkData(result); + }); + } + + private processRossTalkData(data: any): void { + // Process different types of Ross Talk messages + if (data.status) { + // System status update + console.error('System status update:', data.status); + } + + if (data.panel) { + // Panel state update + const panelData = data.panel; + const panelStatus: PanelStatus = { + id: panelData.$.id || panelData.id, + active: panelData.active === 'true', + mixEffect: panelData.mixEffect, + lastUpdate: new Date() + }; + + this.onStateChange?.({ + panels: { + [panelStatus.id]: panelStatus + } + }); + } + + if (data.input || data.source) { + // Input/Source state update + const inputData = data.input || data.source; + const inputStatus: InputStatus = { + id: inputData.$.id || inputData.id, + name: inputData.name || inputData.$.name, + live: inputData.live === 'true', + preview: inputData.preview === 'true', + lastUpdate: new Date() + }; + + this.onStateChange?.({ + inputs: { + [inputStatus.id]: inputStatus + } + }); + } + } + + private sendCommand(command: string): Promise { + return new Promise((resolve, reject) => { + if (!this.isConnected || !this.ws || this.ws.readyState !== WebSocket.OPEN) { + reject(new Error('Not connected to Ross Ultrix')); + return; + } + + console.error('Sending command:', command); + this.ws.send(command); + resolve(); + }); + } + + async getSystemStatus(): Promise { + await this.sendCommand(''); + // In a real implementation, you'd wait for the response + return { + connected: this.isConnected, + timestamp: new Date().toISOString() + }; + } + + async switchPanel(panelId: string): Promise { + const command = ``; + await this.sendCommand(command); + } + + async takeTransition(meId: string): Promise { + const command = ``; + await this.sendCommand(command); + } + + async autoTransition(meId: string, duration: number): Promise { + const command = ``; + await this.sendCommand(command); + } + + async setPreview(meId: string, sourceId: string): Promise { + const command = ``; + await this.sendCommand(command); + } + + async setProgram(meId: string, sourceId: string): Promise { + const command = ``; + await this.sendCommand(command); + } + + async getSources(): Promise { + await this.sendCommand(''); + // In a real implementation, you'd wait for the response and parse it + return [ + { id: '1', name: 'Camera 1', type: 'camera' }, + { id: '2', name: 'Camera 2', type: 'camera' }, + { id: '3', name: 'Graphics', type: 'cg' }, + { id: '4', name: 'Media Player', type: 'media' } + ]; + } + + async getPanels(): Promise { + await this.sendCommand(''); + // In a real implementation, you'd wait for the response and parse it + return [ + { id: 'ME1', name: 'Mix Effect 1', active: true }, + { id: 'ME2', name: 'Mix Effect 2', active: false }, + { id: 'AUX1', name: 'Auxiliary 1', active: false } + ]; + } + + async runMacro(macroId: string): Promise { + const command = ``; + await this.sendCommand(command); + } +}