feat: ledlab

This commit is contained in:
2025-10-11 17:46:32 +02:00
commit 30814807aa
30 changed files with 5690 additions and 0 deletions

483
server/index.js Normal file
View File

@@ -0,0 +1,483 @@
// 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.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.currentPreset ? this.currentPreset.getMetadata().name : null,
matrixSize: { width: this.matrixWidth, height: this.matrixHeight },
nodeCount: this.udpDiscovery.getNodeCount(),
currentTarget: this.currentTarget,
});
});
// 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.currentPreset ? this.currentPreset.getMetadata().name : null,
matrixSize: { width: this.matrixWidth, height: this.matrixHeight },
nodes: this.udpDiscovery.getNodes(),
presetParameters: this.currentPreset ? this.currentPreset.getParameters() : null,
currentTarget: this.currentTarget,
};
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;
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
this.streamingInterval = setInterval(() => {
this.streamFrame();
}, 50); // 20 FPS
// 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);
}
// Also send updated state to keep all clients in sync
this.broadcastCurrentState();
}
}
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.currentPreset ? this.currentPreset.getMetadata().name : null,
matrixSize: { width: this.matrixWidth, height: this.matrixHeight },
nodes: this.udpDiscovery.getNodes(),
presetParameters: this.currentPreset ? this.currentPreset.getParameters() : null,
currentTarget: this.currentTarget,
};
this.broadcastToClients({
type: 'status',
data: currentState
});
}
// 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;

213
server/udp-discovery.js Normal file
View File

@@ -0,0 +1,213 @@
// UDP Discovery service for SPORE nodes
const dgram = require('dgram');
const EventEmitter = require('events');
const os = require('os');
class UdpDiscovery extends EventEmitter {
constructor(port = 4210) {
super();
this.port = port;
this.socket = null;
this.nodes = new Map(); // ip -> { lastSeen, status }
this.discoveryInterval = null;
this.isRunning = false;
// Get local network interfaces to filter out local server
this.localInterfaces = this.getLocalInterfaces();
}
getLocalInterfaces() {
const interfaces = os.networkInterfaces();
const localIPs = new Set();
Object.values(interfaces).forEach(iface => {
iface.forEach(addr => {
if (addr.family === 'IPv4' && !addr.internal) {
localIPs.add(addr.address);
}
});
});
return localIPs;
}
start() {
if (this.isRunning) {
return;
}
this.socket = dgram.createSocket('udp4');
this.isRunning = true;
this.socket.on('message', (msg, rinfo) => {
this.handleMessage(msg, rinfo);
});
this.socket.on('error', (err) => {
console.error('UDP Discovery socket error:', err);
this.emit('error', err);
});
this.socket.bind(this.port, () => {
console.log(`UDP Discovery listening on port ${this.port}`);
// Enable broadcast after binding
this.socket.setBroadcast(true);
this.emit('started');
});
// Start periodic discovery broadcast
this.startDiscoveryBroadcast();
}
stop() {
if (!this.isRunning) {
return;
}
this.isRunning = false;
if (this.discoveryInterval) {
clearInterval(this.discoveryInterval);
this.discoveryInterval = null;
}
if (this.socket) {
this.socket.close();
this.socket = null;
}
this.nodes.clear();
console.log('UDP Discovery stopped');
this.emit('stopped');
}
handleMessage(msg, rinfo) {
const message = msg.toString('utf8');
const nodeIp = rinfo.address;
// Skip local server IPs
if (this.localInterfaces.has(nodeIp)) {
return;
}
// Update node last seen time
this.nodes.set(nodeIp, {
lastSeen: Date.now(),
status: 'connected',
address: nodeIp,
port: rinfo.port
});
// Emit node discovered/updated event
this.emit('nodeDiscovered', {
ip: nodeIp,
port: rinfo.port,
status: 'connected'
});
// Clean up stale nodes periodically
this.cleanupStaleNodes();
}
startDiscoveryBroadcast() {
// Broadcast discovery message every 5 seconds
this.discoveryInterval = setInterval(() => {
this.broadcastDiscovery();
}, 5000);
// Send initial broadcast
this.broadcastDiscovery();
}
broadcastDiscovery() {
if (!this.socket) {
return;
}
const discoveryMessage = 'SPORE_DISCOVERY';
const message = Buffer.from(discoveryMessage, 'utf8');
// Broadcast to all nodes on the network (broadcast already enabled in bind callback)
this.socket.send(message, 0, message.length, this.port, '255.255.255.255', (err) => {
if (err) {
console.error('Error broadcasting discovery message:', err);
} else {
console.log('Discovery message broadcasted');
}
});
}
cleanupStaleNodes() {
const now = Date.now();
const staleThreshold = 10000; // 10 seconds
for (const [ip, node] of this.nodes.entries()) {
if (now - node.lastSeen > staleThreshold) {
this.nodes.delete(ip);
this.emit('nodeLost', { ip, status: 'disconnected' });
}
}
}
getNodes() {
const nodes = Array.from(this.nodes.entries()).map(([ip, node]) => ({
ip,
...node
}));
// Add broadcast option
nodes.unshift({
ip: 'broadcast',
status: 'broadcast',
address: '255.255.255.255',
port: this.port,
isBroadcast: true
});
return nodes;
}
getNodeCount() {
return this.nodes.size;
}
sendToNode(nodeIp, message) {
if (!this.socket) {
return false;
}
const buffer = Buffer.from(message, 'utf8');
this.socket.send(buffer, 0, buffer.length, this.port, nodeIp, (err) => {
if (err) {
console.error(`Error sending to node ${nodeIp}:`, err);
return false;
}
return true;
});
return true;
}
broadcastToAll(message) {
if (!this.socket) {
return false;
}
const buffer = Buffer.from(message, 'utf8');
this.socket.setBroadcast(true);
this.socket.send(buffer, 0, buffer.length, this.port, '255.255.255.255', (err) => {
if (err) {
console.error('Error broadcasting message:', err);
return false;
}
return true;
});
return true;
}
}
module.exports = UdpDiscovery;