feat: use gateway

This commit is contained in:
2025-10-27 09:37:20 +01:00
parent 7b2f600f8c
commit 858be416eb
8 changed files with 279 additions and 255 deletions

View File

@@ -23,7 +23,8 @@ LEDLab is a tool for streaming animations to LED matrices connected to SPORE nod
The Node.js server provides the backend for SPORE LEDLab: The Node.js server provides the backend for SPORE LEDLab:
- **UDP Discovery**: Listens on port 4210 to automatically discover SPORE nodes - **Gateway Integration**: Queries spore-gateway for node discovery (requires spore-gateway to be running)
- **UDP Frame Streaming**: Sends animation frames to SPORE nodes via UDP on port 4210
- **WebSocket API**: Real-time bidirectional communication with the web UI - **WebSocket API**: Real-time bidirectional communication with the web UI
- **Preset Management**: Manages animation presets with configurable parameters - **Preset Management**: Manages animation presets with configurable parameters
- **Multi-Node Streaming**: Streams different presets to individual nodes simultaneously - **Multi-Node Streaming**: Streams different presets to individual nodes simultaneously
@@ -50,6 +51,7 @@ Web UI (Browser) <--WebSocket--> Server <--UDP--> SPORE Nodes
Preset Engine Preset Engine
| |
Frame Generation (60fps) Frame Generation (60fps)
Gateway API (Discovery)
``` ```
## Build ## Build
@@ -58,6 +60,7 @@ Web UI (Browser) <--WebSocket--> Server <--UDP--> SPORE Nodes
- Node.js (v14 or higher) - Node.js (v14 or higher)
- npm (v6 or higher) - npm (v6 or higher)
- spore-gateway must be running on port 3001
### Installation ### Installation
@@ -83,7 +86,8 @@ This will install:
spore-ledlab/ spore-ledlab/
├── server/ # Backend server ├── server/ # Backend server
│ ├── index.js # Main server & WebSocket handling │ ├── index.js # Main server & WebSocket handling
── udp-discovery.js # UDP node discovery ── gateway-client.js # Gateway client for node discovery
│ └── udp-discovery.js # UDP sender for frame streaming
├── presets/ # Animation preset implementations ├── presets/ # Animation preset implementations
│ ├── preset-registry.js │ ├── preset-registry.js
│ ├── base-preset.js │ ├── base-preset.js
@@ -117,17 +121,18 @@ npm run dev
``` ```
The server will: The server will:
- Start the HTTP server on port 3000 - Start the HTTP server on port 8080 (default)
- Initialize WebSocket server - Initialize WebSocket server
- Begin UDP discovery on port 4210 - Connect to spore-gateway for node discovery
- Serve the web UI at http://localhost:3000 - Initialize UDP sender for frame streaming
- Serve the web UI at http://localhost:8080
### Access the Web UI ### Access the Web UI
Open your browser and navigate to: Open your browser and navigate to:
``` ```
http://localhost:3000 http://localhost:8080
``` ```
### Configure SPORE Nodes ### Configure SPORE Nodes
@@ -136,8 +141,30 @@ Ensure your SPORE nodes are:
1. Connected to the same network as the LEDLab server 1. Connected to the same network as the LEDLab server
2. Running firmware with PixelStreamController support 2. Running firmware with PixelStreamController support
3. Listening on UDP port 4210 3. Listening on UDP port 4210
4. Discovered by spore-gateway
5. Labeled with `app: pixelstream` (LEDLab only displays nodes with this label)
The nodes will automatically appear in the LEDLab grid view once discovered. The nodes will automatically appear in the LEDLab grid view once they are discovered by spore-gateway and have the `app: pixelstream` label.
### Node Labeling
To display nodes in LEDLab, you must set the `app: pixelstream` label on your SPORE nodes. This can be done via the spore-gateway API or from the SPORE node itself.
**From SPORE Node:**
Nodes should advertise their labels via the UDP heartbeat. The PixelStreamController firmware automatically sets the `app: pixelstream` label.
**Manual via Gateway:**
You can also set labels manually using the spore-gateway API or spore-ui interface.
### Disabling Filtering
To display all nodes without filtering, set the environment variable:
```bash
FILTER_APP_LABEL= npm start
```
Or leave it empty to show all discovered nodes.
## Usage ## Usage
@@ -190,16 +217,26 @@ In the Settings view:
### Server Configuration ### Server Configuration
Edit `server/index.js` to modify: Edit `server/index.js` or use environment variables:
```javascript ```javascript
const PORT = 3000; // HTTP/WebSocket port const PORT = 8080; // HTTP/WebSocket port
const UDP_PORT = 4210; // UDP discovery port const UDP_PORT = 4210; // UDP frame streaming port
const DEFAULT_FPS = 20; // Default frame rate const GATEWAY_URL = 'http://localhost:3001'; // spore-gateway URL
const MATRIX_WIDTH = 16; // Default matrix width const FILTER_APP_LABEL = 'pixelstream'; // Filter nodes by app label
const MATRIX_HEIGHT = 16; // Default matrix height const DEFAULT_FPS = 20; // Default frame rate
const MATRIX_WIDTH = 16; // Default matrix width
const MATRIX_HEIGHT = 16; // Default matrix height
``` ```
Environment variables:
- `PORT` - HTTP server port (default: 8080)
- `UDP_PORT` - UDP port for frame streaming (default: 4210)
- `GATEWAY_URL` - spore-gateway URL (default: http://localhost:3001)
- `FILTER_APP_LABEL` - Filter nodes by app label (default: pixelstream, set to empty string to disable filtering)
- `MATRIX_WIDTH` - Default matrix width (default: 16)
- `MATRIX_HEIGHT` - Default matrix height (default: 16)
### Adding Custom Presets ### Adding Custom Presets
1. Create a new preset file in `presets/`: 1. Create a new preset file in `presets/`:
@@ -323,14 +360,17 @@ See `presets/examples/README.md` for detailed documentation.
### Nodes Not Appearing ### Nodes Not Appearing
- Ensure spore-gateway is running on port 3001
- Verify nodes are on the same network - Verify nodes are on the same network
- Check firewall settings allow UDP port 4210 - Check firewall settings allow UDP port 4210
- Ensure nodes are running PixelStreamController firmware - Ensure nodes are running PixelStreamController firmware
- Verify spore-gateway has discovered the nodes
- **IMPORTANT**: Nodes must have the `app: pixelstream` label set. LEDLab only displays nodes with this label. Check node labels in spore-gateway.
### WebSocket Connection Issues ### WebSocket Connection Issues
- Check browser console for errors - Check browser console for errors
- Verify server is running on port 3000 - Verify server is running on port 8080 (default)
- Try refreshing the browser - Try refreshing the browser
### Animation Not Streaming ### Animation Not Streaming
@@ -351,7 +391,8 @@ See `presets/examples/README.md` for detailed documentation.
- Node.js - Node.js
- Express.js (HTTP server) - Express.js (HTTP server)
- ws (WebSocket server) - ws (WebSocket server)
- UDP (Node discovery and frame streaming) - HTTP client (for querying spore-gateway)
- UDP (Frame streaming only)
**Frontend:** **Frontend:**
- Vanilla JavaScript (ES6+) - Vanilla JavaScript (ES6+)

View File

@@ -916,7 +916,6 @@ body {
color: var(--text-primary); color: var(--text-primary);
border: 1px solid var(--border-secondary); border: 1px solid var(--border-secondary);
border-radius: 6px; border-radius: 6px;
padding: 0.5rem 1rem;
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;

160
server/gateway-client.js Normal file
View File

@@ -0,0 +1,160 @@
// Gateway Client - Communicates with spore-gateway for node discovery
const http = require('http');
class GatewayClient {
constructor(options = {}) {
this.gatewayUrl = options.gatewayUrl || 'http://localhost:3001';
this.pollInterval = options.pollInterval || 2000; // Poll every 2 seconds
this.filterAppLabel = options.filterAppLabel || 'pixelstream'; // Filter nodes by app label, set to null to disable
this.nodes = new Map(); // ip -> { lastSeen, status, hostname, port }
this.isRunning = false;
this.pollTimer = null;
}
start() {
if (this.isRunning) {
return;
}
this.isRunning = true;
console.log(`Starting Gateway client, connecting to ${this.gatewayUrl}`);
// Initial fetch
this.fetchNodes();
// Start polling
this.pollTimer = setInterval(() => {
this.fetchNodes();
}, this.pollInterval);
}
stop() {
if (!this.isRunning) {
return;
}
this.isRunning = false;
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
this.nodes.clear();
console.log('Gateway client stopped');
}
async fetchNodes() {
try {
const response = await this.httpGet(`${this.gatewayUrl}/api/discovery/nodes`);
const data = JSON.parse(response);
// Update nodes from gateway response
const newNodes = new Map();
let totalNodes = 0;
let filteredNodes = 0;
if (data.nodes && Array.isArray(data.nodes)) {
totalNodes = data.nodes.length;
data.nodes.forEach(node => {
// Filter for nodes with specified app label (if filtering is enabled)
if (this.filterAppLabel && !this.hasAppLabel(node, this.filterAppLabel)) {
filteredNodes++;
return;
}
const nodeIp = node.ip;
newNodes.set(nodeIp, {
lastSeen: Date.now(),
status: node.status || 'active',
hostname: node.hostname || nodeIp,
port: node.port || 4210,
isPrimary: node.isPrimary || false
});
});
//if (totalNodes > 0 && filteredNodes > 0 && this.filterAppLabel) {
// console.loh(`Filtered ${filteredNodes} nodes without app: ${this.filterAppLabel} label (${newNodes.size} ${this.filterAppLabel} nodes active)`);
//}
}
// Check for newly discovered nodes
for (const [ip, nodeInfo] of newNodes.entries()) {
const existingNode = this.nodes.get(ip);
if (!existingNode) {
console.log(`Node discovered via gateway: ${ip} (${nodeInfo.hostname})`);
this.nodes.set(ip, nodeInfo);
// Could emit an event here if needed: this.emit('nodeDiscovered', nodeInfo);
} else if (existingNode.hostname !== nodeInfo.hostname) {
console.log(`Node hostname updated: ${ip} -> ${nodeInfo.hostname}`);
this.nodes.set(ip, nodeInfo);
}
}
// Check for lost nodes
for (const ip of this.nodes.keys()) {
if (!newNodes.has(ip)) {
console.log(`Node lost via gateway: ${ip}`);
this.nodes.delete(ip);
// Could emit an event here if needed: this.emit('nodeLost', { ip });
}
}
} catch (error) {
console.error('Error fetching nodes from gateway:', error.message);
}
}
httpGet(url) {
return new Promise((resolve, reject) => {
http.get(url, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode === 200) {
resolve(data);
} else {
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
}
});
res.on('error', (err) => {
reject(err);
});
}).on('error', (err) => {
reject(err);
});
});
}
getNodes() {
return Array.from(this.nodes.entries()).map(([ip, node]) => ({
ip,
hostname: node.hostname || ip,
port: node.port,
status: node.status,
...node
}));
}
getNodeCount() {
return this.nodes.size;
}
hasAppLabel(node, appLabel) {
// Check if node has the app: <appLabel> label
if (!node.labels || typeof node.labels !== 'object') {
return false;
}
return node.labels.app === appLabel;
}
}
module.exports = GatewayClient;

View File

@@ -6,13 +6,16 @@ const WebSocket = require('ws');
const path = require('path'); const path = require('path');
// Import services // Import services
const UdpDiscovery = require('./udp-discovery'); const UdpSender = require('./udp-discovery');
const GatewayClient = require('./gateway-client');
const PresetRegistry = require('../presets/preset-registry'); const PresetRegistry = require('../presets/preset-registry');
class LEDLabServer { class LEDLabServer {
constructor(options = {}) { constructor(options = {}) {
this.port = options.port || 8080; this.port = options.port || 8080;
this.udpPort = options.udpPort || 4210; this.udpPort = options.udpPort || 4210;
this.gatewayUrl = options.gatewayUrl || 'http://localhost:3001';
this.filterAppLabel = options.filterAppLabel || 'pixelstream';
this.matrixWidth = options.matrixWidth || 16; this.matrixWidth = options.matrixWidth || 16;
this.matrixHeight = options.matrixHeight || 16; this.matrixHeight = options.matrixHeight || 16;
this.fps = options.fps || 20; this.fps = options.fps || 20;
@@ -21,7 +24,11 @@ class LEDLabServer {
this.server = http.createServer(this.app); this.server = http.createServer(this.app);
this.wss = new WebSocket.Server({ server: this.server }); this.wss = new WebSocket.Server({ server: this.server });
this.udpDiscovery = new UdpDiscovery(this.udpPort); this.udpSender = new UdpSender(this.udpPort);
this.gatewayClient = new GatewayClient({
gatewayUrl: this.gatewayUrl,
filterAppLabel: this.filterAppLabel || 'pixelstream'
});
this.presetRegistry = new PresetRegistry(); this.presetRegistry = new PresetRegistry();
// Legacy single-stream support (kept for backwards compatibility) // Legacy single-stream support (kept for backwards compatibility)
@@ -37,7 +44,7 @@ class LEDLabServer {
this.setupExpress(); this.setupExpress();
this.setupWebSocket(); this.setupWebSocket();
this.setupUdpDiscovery(); this.setupGatewayClient();
this.setupPresetManager(); this.setupPresetManager();
} }
@@ -47,7 +54,7 @@ class LEDLabServer {
// API routes // API routes
this.app.get('/api/nodes', (req, res) => { this.app.get('/api/nodes', (req, res) => {
const nodes = this.udpDiscovery.getNodes(); const nodes = this.gatewayClient.getNodes();
res.json({ nodes }); res.json({ nodes });
}); });
@@ -61,7 +68,7 @@ class LEDLabServer {
streaming: this.currentPreset !== null, streaming: this.currentPreset !== null,
currentPreset: this.currentPresetName || null, currentPreset: this.currentPresetName || null,
matrixSize: { width: this.matrixWidth, height: this.matrixHeight }, matrixSize: { width: this.matrixWidth, height: this.matrixHeight },
nodeCount: this.udpDiscovery.getNodeCount(), nodeCount: this.gatewayClient.getNodeCount(),
currentTarget: this.currentTarget, currentTarget: this.currentTarget,
fps: this.fps, fps: this.fps,
}); });
@@ -122,7 +129,7 @@ class LEDLabServer {
streaming: this.currentPreset !== null, streaming: this.currentPreset !== null,
currentPreset: this.currentPresetName || null, currentPreset: this.currentPresetName || null,
matrixSize: { width: this.matrixWidth, height: this.matrixHeight }, matrixSize: { width: this.matrixWidth, height: this.matrixHeight },
nodes: this.udpDiscovery.getNodes(), nodes: this.gatewayClient.getNodes(),
presetParameters: this.currentPreset ? this.currentPreset.getParameters() : null, presetParameters: this.currentPreset ? this.currentPreset.getParameters() : null,
currentTarget: this.currentTarget, currentTarget: this.currentTarget,
fps: this.fps, fps: this.fps,
@@ -174,26 +181,14 @@ class LEDLabServer {
} }
} }
setupUdpDiscovery() { setupGatewayClient() {
this.udpDiscovery.on('nodeDiscovered', (node) => { // Start gateway client for node discovery
console.log('Node discovered:', node.ip); this.gatewayClient.start();
this.broadcastToClients({ // Start UDP sender for sending frames
type: 'nodeDiscovered', this.udpSender.start();
node
}); console.log('Using gateway for node discovery and UDP sender for frame streaming');
});
this.udpDiscovery.on('nodeLost', (node) => {
console.log('Node lost:', node.ip);
this.broadcastToClients({
type: 'nodeLost',
node
});
});
this.udpDiscovery.start();
} }
setupPresetManager() { setupPresetManager() {
@@ -441,7 +436,9 @@ class LEDLabServer {
if (frameData) { if (frameData) {
// Send to specific target // Send to specific target
if (this.currentTarget) { if (this.currentTarget) {
this.udpDiscovery.sendToNode(this.currentTarget, frameData); this.udpSender.sendToNode(this.currentTarget, frameData).catch(err => {
console.error(`Error sending frame to ${this.currentTarget}:`, err);
});
} }
// Send frame data to WebSocket clients for preview // Send frame data to WebSocket clients for preview
@@ -463,7 +460,9 @@ class LEDLabServer {
if (frameData) { if (frameData) {
// Send to specific node // Send to specific node
// frameData format: "RAW:FF0000FF0000..." (RAW prefix + hex pixel data) // frameData format: "RAW:FF0000FF0000..." (RAW prefix + hex pixel data)
this.udpDiscovery.sendToNode(nodeIp, frameData); this.udpSender.sendToNode(nodeIp, frameData).catch(err => {
console.error(`Error sending frame to ${nodeIp}:`, err);
});
// Send frame data to WebSocket clients for preview // Send frame data to WebSocket clients for preview
this.broadcastToClients({ this.broadcastToClients({
@@ -476,7 +475,7 @@ class LEDLabServer {
} }
sendToSpecificNode(nodeIp, message) { sendToSpecificNode(nodeIp, message) {
return this.udpDiscovery.sendToNode(nodeIp, message); return this.udpSender.sendToNode(nodeIp, message);
} }
broadcastCurrentState() { broadcastCurrentState() {
@@ -484,7 +483,7 @@ class LEDLabServer {
streaming: this.currentPreset !== null, streaming: this.currentPreset !== null,
currentPreset: this.currentPresetName || null, currentPreset: this.currentPresetName || null,
matrixSize: { width: this.matrixWidth, height: this.matrixHeight }, matrixSize: { width: this.matrixWidth, height: this.matrixHeight },
nodes: this.udpDiscovery.getNodes(), nodes: this.gatewayClient.getNodes(),
presetParameters: this.currentPreset ? this.currentPreset.getParameters() : null, presetParameters: this.currentPreset ? this.currentPreset.getParameters() : null,
currentTarget: this.currentTarget, currentTarget: this.currentTarget,
fps: this.fps, fps: this.fps,
@@ -604,7 +603,8 @@ class LEDLabServer {
startServer() { startServer() {
this.server.listen(this.port, () => { this.server.listen(this.port, () => {
console.log(`LEDLab server running on port ${this.port}`); console.log(`LEDLab server running on port ${this.port}`);
console.log(`UDP discovery on port ${this.udpPort}`); console.log(`Gateway client connecting to ${this.gatewayUrl}`);
console.log(`UDP sender configured for port ${this.udpPort}`);
console.log(`Matrix size: ${this.matrixWidth}x${this.matrixHeight}`); console.log(`Matrix size: ${this.matrixWidth}x${this.matrixHeight}`);
}); });
} }
@@ -615,8 +615,9 @@ class LEDLabServer {
// Stop streaming first // Stop streaming first
this.stopStreaming(); this.stopStreaming();
// Stop UDP discovery // Stop gateway client and UDP sender
this.udpDiscovery.stop(); this.gatewayClient.stop();
this.udpSender.stop();
// Close all WebSocket connections immediately // Close all WebSocket connections immediately
this.wss.close(); this.wss.close();
@@ -646,6 +647,8 @@ if (require.main === module) {
const server = new LEDLabServer({ const server = new LEDLabServer({
port: process.env.PORT || 8080, port: process.env.PORT || 8080,
udpPort: process.env.UDP_PORT || 4210, udpPort: process.env.UDP_PORT || 4210,
gatewayUrl: process.env.GATEWAY_URL || 'http://localhost:3001',
filterAppLabel: process.env.FILTER_APP_LABEL || 'pixelstream',
matrixWidth: parseInt(process.env.MATRIX_WIDTH) || 16, matrixWidth: parseInt(process.env.MATRIX_WIDTH) || 16,
matrixHeight: parseInt(process.env.MATRIX_HEIGHT) || 16, matrixHeight: parseInt(process.env.MATRIX_HEIGHT) || 16,
}); });

View File

@@ -1,35 +1,12 @@
// UDP Discovery service for SPORE nodes // UDP Sender service for sending frames to SPORE nodes
const dgram = require('dgram'); const dgram = require('dgram');
const EventEmitter = require('events');
const os = require('os');
class UdpDiscovery extends EventEmitter { class UdpSender {
constructor(port = 4210) { constructor(port = 4210) {
super();
this.port = port; this.port = port;
this.socket = null; this.socket = null;
this.nodes = new Map(); // ip -> { lastSeen, status }
this.discoveryInterval = null;
this.isRunning = false; 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() { start() {
@@ -40,24 +17,15 @@ class UdpDiscovery extends EventEmitter {
this.socket = dgram.createSocket('udp4'); this.socket = dgram.createSocket('udp4');
this.isRunning = true; this.isRunning = true;
this.socket.on('message', (msg, rinfo) => {
this.handleMessage(msg, rinfo);
});
this.socket.on('error', (err) => { this.socket.on('error', (err) => {
console.error('UDP Discovery socket error:', err); console.error('UDP Sender socket error:', err);
this.emit('error', err);
}); });
this.socket.bind(this.port, () => { this.socket.bind(0, () => {
console.log(`UDP Discovery listening on port ${this.port}`); // Bind to any available port
// Enable broadcast after binding
this.socket.setBroadcast(true); this.socket.setBroadcast(true);
this.emit('started'); console.log(`UDP Sender ready on port ${this.socket.address().port}`);
}); });
// Start periodic discovery broadcast
this.startDiscoveryBroadcast();
} }
stop() { stop() {
@@ -67,162 +35,12 @@ class UdpDiscovery extends EventEmitter {
this.isRunning = false; this.isRunning = false;
if (this.discoveryInterval) {
clearInterval(this.discoveryInterval);
this.discoveryInterval = null;
}
if (this.socket) { if (this.socket) {
this.socket.close(); this.socket.close();
this.socket = null; this.socket = null;
} }
this.nodes.clear(); console.log('UDP Sender stopped');
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;
}
// Handle different message types
if (message.startsWith('cluster/heartbeat:')) {
// Extract hostname from heartbeat: "cluster/heartbeat:hostname"
const hostname = message.substring('cluster/heartbeat:'.length);
this.handleHeartbeat(hostname, nodeIp, rinfo.port);
} else if (message.startsWith('node/update:')) {
// Extract hostname and JSON from update: "node/update:hostname:{json}"
const parts = message.substring('node/update:'.length).split(':');
if (parts.length >= 2) {
const hostname = parts[0];
const jsonStr = parts.slice(1).join(':'); // Rejoin in case JSON contains colons
this.handleNodeUpdate(hostname, jsonStr, nodeIp, rinfo.port);
}
}
}
handleHeartbeat(hostname, nodeIp, port) {
console.log(`Heartbeat from ${hostname} @ ${nodeIp}`);
// Update or add node
const existingNode = this.nodes.get(nodeIp);
this.nodes.set(nodeIp, {
lastSeen: Date.now(),
status: 'connected',
address: nodeIp,
port: port,
hostname: hostname
});
// Only emit if this is a new node or if we need to update
if (!existingNode || existingNode.hostname !== hostname) {
this.emit('nodeDiscovered', {
ip: nodeIp,
hostname: hostname,
port: port,
status: 'connected'
});
}
// Clean up stale nodes periodically
this.cleanupStaleNodes();
}
handleNodeUpdate(hostname, jsonStr, nodeIp, port) {
console.log(`Node update from ${hostname} @ ${nodeIp}`);
// Try to parse JSON to extract additional info
let nodeInfo = {};
try {
nodeInfo = JSON.parse(jsonStr);
} catch (e) {
console.warn(`Failed to parse node update JSON: ${e.message}`);
}
// Update node with hostname and any additional info
const existingNode = this.nodes.get(nodeIp);
this.nodes.set(nodeIp, {
lastSeen: Date.now(),
status: 'connected',
address: nodeIp,
port: port,
hostname: hostname || nodeInfo.hostname || existingNode?.hostname,
...nodeInfo
});
// Emit update event
this.emit('nodeDiscovered', {
ip: nodeIp,
hostname: hostname || nodeInfo.hostname || existingNode?.hostname,
port: port,
status: 'connected'
});
}
startDiscoveryBroadcast() {
// With the new protocol, SPORE nodes automatically broadcast heartbeats
// LEDLab passively listens for these heartbeats, so we don't need to broadcast.
// However, we can optionally send a heartbeat to prompt nodes to respond faster.
// For now, we just listen for incoming heartbeats from nodes.
// Optional: send initial heartbeat to prompt nodes to announce themselves
this.broadcastHeartbeat();
// Send periodic heartbeats to prompt node announcements (every 10 seconds)
this.discoveryInterval = setInterval(() => {
this.broadcastHeartbeat();
}, 10000);
}
broadcastHeartbeat() {
if (!this.socket) {
return;
}
// Send heartbeat using the new protocol format: "cluster/heartbeat:hostname"
const hostname = 'ledlab-client';
const discoveryMessage = `cluster/heartbeat:${hostname}`;
const message = Buffer.from(discoveryMessage, 'utf8');
this.socket.send(message, 0, message.length, this.port, '255.255.255.255', (err) => {
if (err) {
console.error('Error broadcasting heartbeat:', err);
} else {
console.log('Discovery heartbeat 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,
hostname: node.hostname || ip,
...node
}));
return nodes;
}
getNodeCount() {
return this.nodes.size;
} }
sendToNode(nodeIp, message) { sendToNode(nodeIp, message) {
@@ -231,15 +49,17 @@ class UdpDiscovery extends EventEmitter {
} }
const buffer = Buffer.from(message, 'utf8'); const buffer = Buffer.from(message, 'utf8');
this.socket.send(buffer, 0, buffer.length, this.port, nodeIp, (err) => {
if (err) { return new Promise((resolve, reject) => {
console.error(`Error sending to node ${nodeIp}:`, err); this.socket.send(buffer, 0, buffer.length, this.port, nodeIp, (err) => {
return false; if (err) {
} console.error(`Error sending to node ${nodeIp}:`, err);
return true; reject(err);
return;
}
resolve(true);
});
}); });
return true;
} }
broadcastToAll(message) { broadcastToAll(message) {
@@ -250,16 +70,17 @@ class UdpDiscovery extends EventEmitter {
const buffer = Buffer.from(message, 'utf8'); const buffer = Buffer.from(message, 'utf8');
this.socket.setBroadcast(true); this.socket.setBroadcast(true);
this.socket.send(buffer, 0, buffer.length, this.port, '255.255.255.255', (err) => { return new Promise((resolve, reject) => {
if (err) { this.socket.send(buffer, 0, buffer.length, this.port, '255.255.255.255', (err) => {
console.error('Error broadcasting message:', err); if (err) {
return false; console.error('Error broadcasting message:', err);
} reject(err);
return true; return;
}
resolve(true);
});
}); });
return true;
} }
} }
module.exports = UdpDiscovery; module.exports = UdpSender;