// 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(); this.currentPreset = null; this.currentPresetName = null; this.streamingInterval = null; this.connectedClients = new Set(); // Per-node configurations and current target this.nodeConfigurations = new Map(); // ip -> {presetName, parameters, matrixSize} this.currentTarget = null; // 'broadcast' or specific 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.currentTarget !== 'broadcast') { 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); break; case 'stopStreaming': this.stopStreaming(); break; case 'updatePresetParameter': this.updatePresetParameter(data.parameter, data.value); break; case 'setMatrixSize': this.setMatrixSize(data.width, data.height); break; case 'selectNode': this.selectNode(data.nodeIp); break; case 'selectBroadcast': this.selectBroadcast(); break; case 'sendToNode': this.sendToSpecificNode(data.nodeIp, data.message); break; case 'broadcastToAll': this.broadcastToAllNodes(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 preset this.currentPreset = null; } startPreset(presetName, width = this.matrixWidth, height = this.matrixHeight) { try { // Stop current streaming if active if (this.currentPreset) { this.stopStreaming(); } // Create new preset instance this.currentPreset = this.presetRegistry.createPreset(presetName, width, height); this.currentPresetName = presetName; // Store the registry key this.currentPreset.start(); console.log(`Started preset: ${presetName} (${width}x${height})`); // Start streaming interval const intervalMs = Math.floor(1000 / this.fps); this.streamingInterval = setInterval(() => { this.streamFrame(); }, intervalMs); // Notify clients this.broadcastToClients({ type: 'streamingStarted', preset: this.currentPreset.getMetadata() }); // Also send updated state to keep all clients in sync this.broadcastCurrentState(); } catch (error) { console.error('Error starting preset:', error); this.sendToClient(ws, { type: 'error', message: `Failed to start preset: ${error.message}` }); } } stopStreaming() { 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'); this.broadcastToClients({ type: 'streamingStopped' }); // Save current configuration for the current target if it exists if (this.currentTarget && this.currentTarget !== 'broadcast') { this.saveCurrentConfiguration(this.currentTarget); } // Also send updated state to keep all clients in sync this.broadcastCurrentState(); } updatePresetParameter(parameter, value) { if (this.currentPreset) { this.currentPreset.setParameter(parameter, value); this.broadcastToClients({ type: 'presetParameterUpdated', parameter, value }); // Save updated configuration for current target if (this.currentTarget && this.currentTarget !== 'broadcast') { this.saveCurrentConfiguration(this.currentTarget); } // 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.currentTarget !== 'broadcast') { this.saveCurrentConfiguration(this.currentTarget); } } streamFrame() { if (!this.currentPreset) { return; } const frameData = this.currentPreset.generateFrame(); if (frameData) { // Send to specific target or broadcast if (this.currentTarget === 'broadcast') { this.udpDiscovery.broadcastToAll(frameData); } else 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() }); } } sendToSpecificNode(nodeIp, message) { return this.udpDiscovery.sendToNode(nodeIp, message); } broadcastToAllNodes(message) { return this.udpDiscovery.broadcastToAll(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`); // If streaming is active, restart the interval with new frame rate if (this.currentPreset && this.streamingInterval) { clearInterval(this.streamingInterval); const intervalMs = Math.floor(1000 / this.fps); 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; // Load configuration for this node if it exists, otherwise use current settings const nodeConfig = this.nodeConfigurations.get(nodeIp); if (nodeConfig) { this.loadNodeConfiguration(nodeConfig); } else { // Save current configuration for this node this.saveCurrentConfiguration(nodeIp); } this.broadcastCurrentState(); } selectBroadcast() { this.currentTarget = 'broadcast'; this.broadcastCurrentState(); } saveCurrentConfiguration(nodeIp) { if (this.currentPreset && this.currentPresetName) { this.nodeConfigurations.set(nodeIp, { presetName: this.currentPresetName, // Use registry key, not display name parameters: this.currentPreset.getParameters(), matrixSize: { width: this.matrixWidth, height: this.matrixHeight } }); } } loadNodeConfiguration(config) { // Stop current streaming this.stopStreaming(); // Load the node's configuration this.matrixWidth = config.matrixSize.width; this.matrixHeight = config.matrixSize.height; // Start the preset with saved parameters this.startPreset(config.presetName, config.matrixSize.width, config.matrixSize.height); // Set the parameters after preset is created if (this.currentPreset) { Object.entries(config.parameters).forEach(([param, value]) => { this.currentPreset.setParameter(param, value); }); } } 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;