Add core MCP server implementation and Ross Talk protocol handler: ross-talk.ts

This commit is contained in:
Zac Gaetano 2026-05-03 23:49:02 -04:00
parent b9f387b53f
commit 0bb71d63bb

242
src/ross-talk.ts Normal file
View file

@ -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<RossUltrixState>) => 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<void> {
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<void> {
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('<heartbeat/>');
}
}, 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<void> {
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<any> {
await this.sendCommand('<status/>');
// In a real implementation, you'd wait for the response
return {
connected: this.isConnected,
timestamp: new Date().toISOString()
};
}
async switchPanel(panelId: string): Promise<void> {
const command = `<panel id="${panelId}" action="switch"/>`;
await this.sendCommand(command);
}
async takeTransition(meId: string): Promise<void> {
const command = `<me id="${meId}" action="take"/>`;
await this.sendCommand(command);
}
async autoTransition(meId: string, duration: number): Promise<void> {
const command = `<me id="${meId}" action="auto" duration="${duration}"/>`;
await this.sendCommand(command);
}
async setPreview(meId: string, sourceId: string): Promise<void> {
const command = `<me id="${meId}" preview="${sourceId}"/>`;
await this.sendCommand(command);
}
async setProgram(meId: string, sourceId: string): Promise<void> {
const command = `<me id="${meId}" program="${sourceId}"/>`;
await this.sendCommand(command);
}
async getSources(): Promise<any[]> {
await this.sendCommand('<sources/>');
// 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<any[]> {
await this.sendCommand('<panels/>');
// 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<void> {
const command = `<macro id="${macroId}" action="run"/>`;
await this.sendCommand(command);
}
}