// LEDLab Server - Main server file const express = require('express'); const http = require('http'); const WebSocket = require('ws'); const path = require('path'); // Import services const UdpDiscovery = require('./udp-discovery'); const PresetRegistry = require('../presets/preset-registry'); class LEDLabServer { constructor(options = {}) { this.port = options.port || 8080; this.udpPort = options.udpPort || 4210; this.matrixWidth = options.matrixWidth || 16; this.matrixHeight = options.matrixHeight || 16; this.fps = options.fps || 20; this.app = express(); this.server = http.createServer(this.app); this.wss = new WebSocket.Server({ server: this.server }); this.udpDiscovery = new UdpDiscovery(this.udpPort); this.presetRegistry = new PresetRegistry(); // Legacy single-stream support (kept for backwards compatibility) this.currentPreset = null; this.currentPresetName = null; this.streamingInterval = null; this.connectedClients = new Set(); // Multi-node streaming support this.nodeStreams = new Map(); // ip -> {preset, presetName, interval, matrixSize, parameters} this.nodeConfigurations = new Map(); // ip -> {presetName, parameters, matrixSize} this.currentTarget = null; // Currently selected node IP this.setupExpress(); this.setupWebSocket(); this.setupUdpDiscovery(); this.setupPresetManager(); } setupExpress() { // Serve static files this.app.use(express.static(path.join(__dirname, '../public'))); // API routes this.app.get('/api/nodes', (req, res) => { const nodes = this.udpDiscovery.getNodes(); res.json({ nodes }); }); this.app.get('/api/presets', (req, res) => { const presets = this.presetRegistry.getAllPresetMetadata(); res.json({ presets }); }); this.app.get('/api/status', (req, res) => { res.json({ streaming: this.currentPreset !== null, currentPreset: this.currentPresetName || null, matrixSize: { width: this.matrixWidth, height: this.matrixHeight }, nodeCount: this.udpDiscovery.getNodeCount(), currentTarget: this.currentTarget, fps: this.fps, }); }); // Matrix configuration endpoint this.app.post('/api/matrix/config', express.json(), (req, res) => { const { width, height } = req.body; if (width && height && width > 0 && height > 0) { this.matrixWidth = width; this.matrixHeight = height; // Restart current preset with new dimensions if one is active if (this.currentPreset) { this.stopStreaming(); this.startPreset(this.currentPreset.constructor.name.toLowerCase().replace('-preset', ''), width, height); } this.broadcastToClients({ type: 'matrixConfig', config: { width, height } }); // Save updated configuration for current target if (this.currentTarget) { this.saveCurrentConfiguration(this.currentTarget); } res.json({ success: true, config: { width, height } }); } else { res.status(400).json({ error: 'Invalid matrix dimensions' }); } }); } setupWebSocket() { this.wss.on('connection', (ws) => { console.log('WebSocket client connected'); this.connectedClients.add(ws); ws.on('message', (message) => { try { const data = JSON.parse(message.toString()); this.handleWebSocketMessage(ws, data); } catch (error) { console.error('Error parsing WebSocket message:', error); } }); ws.on('close', () => { console.log('WebSocket client disconnected'); this.connectedClients.delete(ws); }); // Send current status to new client const currentState = { streaming: this.currentPreset !== null, currentPreset: this.currentPresetName || null, matrixSize: { width: this.matrixWidth, height: this.matrixHeight }, nodes: this.udpDiscovery.getNodes(), presetParameters: this.currentPreset ? this.currentPreset.getParameters() : null, currentTarget: this.currentTarget, fps: this.fps, }; this.sendToClient(ws, { type: 'status', data: currentState }); }); } handleWebSocketMessage(ws, data) { switch (data.type) { case 'startPreset': this.startPreset(data.presetName, data.width, data.height, data.nodeIp, data.parameters); break; case 'stopStreaming': this.stopStreaming(data.nodeIp); break; case 'updatePresetParameter': this.updatePresetParameter(data.parameter, data.value, data.nodeIp); break; case 'setMatrixSize': this.setMatrixSize(data.width, data.height); break; case 'selectNode': this.selectNode(data.nodeIp); break; case 'sendToNode': this.sendToSpecificNode(data.nodeIp, data.message); break; case 'updateFrameRate': this.updateFrameRate(data.fps); break; default: console.warn('Unknown WebSocket message type:', data.type); } } setupUdpDiscovery() { this.udpDiscovery.on('nodeDiscovered', (node) => { console.log('Node discovered:', node.ip); this.broadcastToClients({ type: 'nodeDiscovered', node }); }); this.udpDiscovery.on('nodeLost', (node) => { console.log('Node lost:', node.ip); this.broadcastToClients({ type: 'nodeLost', node }); }); this.udpDiscovery.start(); } setupPresetManager() { // Start with no active presets this.currentPreset = null; this.nodeStreams.clear(); } startPreset(presetName, width = this.matrixWidth, height = this.matrixHeight, nodeIp = null, parameters = null) { try { const targetIp = nodeIp || this.currentTarget; if (!targetIp) { console.warn('No target specified for streaming'); return; } // Stop current streaming for this node if active this.stopStreaming(targetIp); // Create new preset instance const preset = this.presetRegistry.createPreset(presetName, width, height); preset.start(); // Apply parameters if provided if (parameters) { Object.entries(parameters).forEach(([param, value]) => { preset.setParameter(param, value); }); } console.log(`Started preset: ${presetName} (${width}x${height}) for ${targetIp}`); // Start streaming interval for this node const intervalMs = Math.floor(1000 / this.fps); const interval = setInterval(() => { this.streamFrameForNode(targetIp); }, intervalMs); // Store stream information this.nodeStreams.set(targetIp, { preset, presetName, interval, matrixSize: { width, height }, parameters: preset.getParameters() }); // Update legacy support if (targetIp === this.currentTarget) { this.currentPreset = preset; this.currentPresetName = presetName; this.streamingInterval = interval; } // Save configuration this.saveCurrentConfiguration(targetIp); // Notify clients this.broadcastToClients({ type: 'streamingStarted', preset: preset.getMetadata(), nodeIp: targetIp }); // Also send updated state to keep all clients in sync this.broadcastCurrentState(); } catch (error) { console.error('Error starting preset:', error); this.broadcastToClients({ type: 'error', message: `Failed to start preset: ${error.message}` }); } } stopStreaming(nodeIp = null) { const targetIp = nodeIp || this.currentTarget; if (targetIp) { this.stopNodeStream(targetIp); } else { // Legacy: stop current streaming if (this.streamingInterval) { clearInterval(this.streamingInterval); this.streamingInterval = null; } if (this.currentPreset) { this.currentPreset.stop(); this.currentPreset = null; this.currentPresetName = null; } } console.log(`Streaming stopped for ${targetIp || 'current target'}`); this.broadcastToClients({ type: 'streamingStopped', nodeIp: targetIp }); // Also send updated state to keep all clients in sync this.broadcastCurrentState(); } stopNodeStream(nodeIp) { const stream = this.nodeStreams.get(nodeIp); if (stream) { clearInterval(stream.interval); stream.preset.stop(); this.nodeStreams.delete(nodeIp); // Update legacy support if this was the current target if (nodeIp === this.currentTarget) { this.currentPreset = null; this.currentPresetName = null; this.streamingInterval = null; } } } updatePresetParameter(parameter, value, nodeIp = null) { const targetIp = nodeIp || this.currentTarget; if (targetIp) { const stream = this.nodeStreams.get(targetIp); if (stream) { stream.preset.setParameter(parameter, value); stream.parameters = stream.preset.getParameters(); this.saveCurrentConfiguration(targetIp); } } // Legacy support if (this.currentPreset && targetIp === this.currentTarget) { this.currentPreset.setParameter(parameter, value); } this.broadcastToClients({ type: 'presetParameterUpdated', parameter, value, nodeIp: targetIp }); // Don't broadcast full state on every parameter change to avoid UI flickering // State is already updated via presetParameterUpdated event } setMatrixSize(width, height) { this.matrixWidth = width; this.matrixHeight = height; if (this.currentPreset) { this.stopStreaming(); this.startPreset(this.currentPreset.constructor.name.toLowerCase().replace('-preset', ''), width, height); } this.broadcastToClients({ type: 'matrixSizeChanged', size: { width, height } }); // Save updated configuration for current target if (this.currentTarget) { this.saveCurrentConfiguration(this.currentTarget); } } streamFrame() { if (!this.currentPreset) { return; } const frameData = this.currentPreset.generateFrame(); if (frameData) { // Send to specific target if (this.currentTarget) { this.udpDiscovery.sendToNode(this.currentTarget, frameData); } // Send frame data to WebSocket clients for preview this.broadcastToClients({ type: 'frame', data: frameData, timestamp: Date.now() }); } } streamFrameForNode(nodeIp) { const stream = this.nodeStreams.get(nodeIp); if (!stream || !stream.preset) { return; } const frameData = stream.preset.generateFrame(); if (frameData) { // Send to specific node this.udpDiscovery.sendToNode(nodeIp, frameData); // Send frame data to WebSocket clients for preview this.broadcastToClients({ type: 'frame', data: frameData, nodeIp: nodeIp, timestamp: Date.now() }); } } sendToSpecificNode(nodeIp, message) { return this.udpDiscovery.sendToNode(nodeIp, message); } broadcastCurrentState() { const currentState = { streaming: this.currentPreset !== null, currentPreset: this.currentPresetName || null, matrixSize: { width: this.matrixWidth, height: this.matrixHeight }, nodes: this.udpDiscovery.getNodes(), presetParameters: this.currentPreset ? this.currentPreset.getParameters() : null, currentTarget: this.currentTarget, fps: this.fps, }; this.broadcastToClients({ type: 'status', data: currentState }); } updateFrameRate(fps) { if (fps < 1 || fps > 60) { console.warn('Invalid FPS value:', fps); return; } this.fps = fps; console.log(`Frame rate updated to ${fps} FPS`); const intervalMs = Math.floor(1000 / this.fps); // Update all active node streams this.nodeStreams.forEach((stream, nodeIp) => { if (stream.interval) { clearInterval(stream.interval); stream.interval = setInterval(() => { this.streamFrameForNode(nodeIp); }, intervalMs); } }); // Legacy: If streaming is active, restart the interval with new frame rate if (this.currentPreset && this.streamingInterval) { clearInterval(this.streamingInterval); this.streamingInterval = setInterval(() => { this.streamFrame(); }, intervalMs); } // Notify clients this.broadcastToClients({ type: 'frameRateUpdated', fps: this.fps }); } // Node selection and configuration management selectNode(nodeIp) { this.currentTarget = nodeIp; // Check if this node already has an active stream const stream = this.nodeStreams.get(nodeIp); if (stream) { // Node is already streaming, update legacy references this.currentPreset = stream.preset; this.currentPresetName = stream.presetName; this.matrixWidth = stream.matrixSize.width; this.matrixHeight = stream.matrixSize.height; } else { // Load configuration for this node if it exists const nodeConfig = this.nodeConfigurations.get(nodeIp); if (nodeConfig) { this.matrixWidth = nodeConfig.matrixSize.width; this.matrixHeight = nodeConfig.matrixSize.height; // Don't auto-start streaming, just load the configuration } } this.broadcastCurrentState(); } saveCurrentConfiguration(nodeIp) { const stream = this.nodeStreams.get(nodeIp); if (stream && stream.preset && stream.presetName) { this.nodeConfigurations.set(nodeIp, { presetName: stream.presetName, parameters: stream.preset.getParameters(), matrixSize: stream.matrixSize }); } else if (this.currentPreset && this.currentPresetName) { // Legacy fallback this.nodeConfigurations.set(nodeIp, { presetName: this.currentPresetName, parameters: this.currentPreset.getParameters(), matrixSize: { width: this.matrixWidth, height: this.matrixHeight } }); } } loadNodeConfiguration(config, nodeIp) { // Start the preset with saved parameters for this specific node this.startPreset( config.presetName, config.matrixSize.width, config.matrixSize.height, nodeIp, config.parameters ); } broadcastToClients(message) { const messageStr = JSON.stringify(message); this.connectedClients.forEach(ws => { if (ws.readyState === WebSocket.OPEN) { ws.send(messageStr); } }); } sendToClient(ws, message) { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(message)); } } startServer() { this.server.listen(this.port, () => { console.log(`LEDLab server running on port ${this.port}`); console.log(`UDP discovery on port ${this.udpPort}`); console.log(`Matrix size: ${this.matrixWidth}x${this.matrixHeight}`); }); } stopServer() { console.log('Stopping LEDLab server...'); // Stop streaming first this.stopStreaming(); // Stop UDP discovery this.udpDiscovery.stop(); // Close all WebSocket connections immediately this.wss.close(); // Close the server this.server.close((err) => { if (err) { console.error('Error closing server:', err); } console.log('LEDLab server stopped'); process.exit(0); }); // Force exit after 2 seconds if server doesn't close cleanly setTimeout(() => { console.log('Forcing server shutdown...'); process.exit(1); }, 2000); } } // Global flag to ensure shutdown handlers are only registered once let shutdownHandlersRegistered = false; // Start server if this file is run directly if (require.main === module) { const server = new LEDLabServer({ port: process.env.PORT || 8080, udpPort: process.env.UDP_PORT || 4210, matrixWidth: parseInt(process.env.MATRIX_WIDTH) || 16, matrixHeight: parseInt(process.env.MATRIX_HEIGHT) || 16, }); // Graceful shutdown (only register once) if (!shutdownHandlersRegistered) { shutdownHandlersRegistered = true; process.on('SIGINT', () => { console.log('\nShutting down LEDLab server...'); server.stopServer(); }); process.on('SIGTERM', () => { console.log('\nShutting down LEDLab server...'); server.stopServer(); }); } server.startServer(); } module.exports = LEDLabServer;