feat: use gateway
This commit is contained in:
71
README.md
71
README.md
@@ -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+)
|
||||||
|
|||||||
@@ -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
160
server/gateway-client.js
Normal 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;
|
||||||
|
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user