Compare commits

...

7 Commits

Author SHA1 Message Date
a0c49a29bc feat: multiarch build 2025-10-27 21:38:01 +01:00
06fc5e6747 feat: add Docker build 2025-10-27 15:19:40 +01:00
be6d9bb98a Merge pull request 'feature/gateway' (#2) from feature/gateway into main
Reviewed-on: #2
2025-10-27 13:10:47 +01:00
7784365361 feat: subscribe to websocket for cluster updates 2025-10-27 13:05:35 +01:00
858be416eb feat: use gateway 2025-10-27 09:37:20 +01:00
7b2f600f8c feat: update to new cluster protocol 2025-10-27 07:55:33 +01:00
9d5d0e4d67 feat: improve editor layout 2025-10-12 17:02:47 +02:00
17 changed files with 970 additions and 288 deletions

View File

@@ -0,0 +1,56 @@
---
description: Guidelines for writing clean, maintainable, and human-readable code. Apply these rules when writing or reviewing code to ensure consistency and quality.
globs:
alwaysApply: true
---
# Clean Code Guidelines
## Constants Over Magic Numbers
- Replace hard-coded values with named constants
- Use descriptive constant names that explain the value's purpose
- Keep constants at the top of the file or in a dedicated constants file
## Meaningful Names
- Variables, functions, and classes should reveal their purpose
- Names should explain why something exists and how it's used
- Avoid abbreviations unless they're universally understood
## Smart Comments
- Don't comment on what the code does - make the code self-documenting
- Use comments to explain why something is done a certain way
- Document APIs, complex algorithms, and non-obvious side effects
## Single Responsibility
- Each function should do exactly one thing
- Functions should be small and focused
- If a function needs a comment to explain what it does, it should be split
## DRY (Don't Repeat Yourself)
- Extract repeated code into reusable functions
- Share common logic through proper abstraction
- Maintain single sources of truth
## Clean Structure
- Keep related code together
- Organize code in a logical hierarchy
- Use consistent file and folder naming conventions
## Encapsulation
- Hide implementation details
- Expose clear interfaces
- Move nested conditionals into well-named functions
## Code Quality Maintenance
- Refactor continuously
- Fix technical debt early
- Leave code cleaner than you found it
## Testing
- Write tests before fixing bugs
- Keep tests readable and maintainable
- Test edge cases and error conditions
## Version Control
- Write clear commit messages
- Make small, focused commits
- Use meaningful branch names

111
.cursor/rules/gitflow.mdc Normal file
View File

@@ -0,0 +1,111 @@
---
description: Gitflow Workflow Rules. These rules should be applied when performing git operations.
---
# Gitflow Workflow Rules
## Main Branches
### main (or master)
- Contains production-ready code
- Never commit directly to main
- Only accepts merges from:
- hotfix/* branches
- release/* branches
- Must be tagged with version number after each merge
### develop
- Main development branch
- Contains latest delivered development changes
- Source branch for feature branches
- Never commit directly to develop
## Supporting Branches
### feature/*
- Branch from: develop
- Merge back into: develop
- Naming convention: feature/[issue-id]-descriptive-name
- Example: feature/123-user-authentication
- Must be up-to-date with develop before creating PR
- Delete after merge
### release/*
- Branch from: develop
- Merge back into:
- main
- develop
- Naming convention: release/vX.Y.Z
- Example: release/v1.2.0
- Only bug fixes, documentation, and release-oriented tasks
- No new features
- Delete after merge
### hotfix/*
- Branch from: main
- Merge back into:
- main
- develop
- Naming convention: hotfix/vX.Y.Z
- Example: hotfix/v1.2.1
- Only for urgent production fixes
- Delete after merge
## Commit Messages
- Format: `type(scope): description`
- Types:
- feat: New feature
- fix: Bug fix
- docs: Documentation changes
- style: Formatting, missing semicolons, etc.
- refactor: Code refactoring
- test: Adding tests
- chore: Maintenance tasks
## Version Control
### Semantic Versioning
- MAJOR version for incompatible API changes
- MINOR version for backwards-compatible functionality
- PATCH version for backwards-compatible bug fixes
## Pull Request Rules
1. All changes must go through Pull Requests
2. Required approvals: minimum 1
3. CI checks must pass
4. No direct commits to protected branches (main, develop)
5. Branch must be up to date before merging
6. Delete branch after merge
## Branch Protection Rules
### main & develop
- Require pull request reviews
- Require status checks to pass
- Require branches to be up to date
- Include administrators in restrictions
- No force pushes
- No deletions
## Release Process
1. Create release branch from develop
2. Bump version numbers
3. Fix any release-specific issues
4. Create PR to main
5. After merge to main:
- Tag release
- Merge back to develop
- Delete release branch
## Hotfix Process
1. Create hotfix branch from main
2. Fix the issue
3. Bump patch version
4. Create PR to main
5. After merge to main:
- Tag release
- Merge back to develop
- Delete hotfix branch

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.git
.gitignore
.cursor
*.md
node_modules
README.md
docs

41
Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Runtime stage
FROM node:20-alpine
# Install wget for health checks
RUN apk --no-cache add wget
WORKDIR /app
# Copy dependencies from builder
COPY --from=builder /app/node_modules ./node_modules
# Copy application files
COPY package*.json ./
COPY server ./server
COPY presets ./presets
COPY public ./public
# Create non-root user (let Alpine assign available GID/UID)
RUN addgroup spore && \
adduser -D -s /bin/sh -G spore spore && \
chown -R spore:spore /app
USER spore
# Expose port
EXPOSE 8080
# Run the application
CMD ["node", "server/index.js"]

54
Makefile Normal file
View File

@@ -0,0 +1,54 @@
.PHONY: install build run clean docker-build docker-run docker-push docker-build-multiarch docker-push-multiarch
# Install dependencies
install:
npm install
# Build the application (if needed)
build: install
# Run the application
run:
node server/index.js
# Start in development mode
dev:
node server/index.js
# Clean build artifacts
clean:
rm -rf node_modules
rm -f package-lock.json
# Docker variables
DOCKER_REGISTRY ?=
IMAGE_NAME = wirelos/spore-ledlab
IMAGE_TAG ?= latest
FULL_IMAGE_NAME = $(if $(DOCKER_REGISTRY),$(DOCKER_REGISTRY)/$(IMAGE_NAME),$(IMAGE_NAME)):$(IMAGE_TAG)
# Build Docker image
docker-build:
docker build -t $(FULL_IMAGE_NAME) .
# Run Docker container
docker-run:
docker run -p 8080:8080 --rm $(FULL_IMAGE_NAME)
# Push Docker image
docker-push:
docker push $(FULL_IMAGE_NAME)
# Build multiarch Docker image
docker-build-multiarch:
docker buildx build --platform linux/amd64,linux/arm64 \
-t $(FULL_IMAGE_NAME) \
--push \
.
# Push multiarch Docker image (if not pushed during build)
docker-push-multiarch:
docker buildx build --platform linux/amd64,linux/arm64 \
-t $(FULL_IMAGE_NAME) \
--push \
.

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**: Subscribes to spore-gateway WebSocket for real-time cluster updates (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 WebSocket (Real-time cluster updates)
``` ```
## 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+)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 226 KiB

119
package-lock.json generated
View File

@@ -9,7 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"dgram": "^1.0.1", "axios": "^1.6.0",
"express": "^4.18.2", "express": "^4.18.2",
"ws": "^8.14.2" "ws": "^8.14.2"
} }
@@ -33,6 +33,23 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.3", "version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@@ -95,6 +112,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -140,6 +169,15 @@
"ms": "2.0.0" "ms": "2.0.0"
} }
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -159,13 +197,6 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/dgram": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dgram/-/dgram-1.0.1.tgz",
"integrity": "sha512-zJVFL1EWfKtE0z2VN6qfpn/a+qG1viEzcwJA0EjtzS76ONSE3sEyWBwEbo32hS4IFw/EWVuWN+8b89aPW6It2A==",
"deprecated": "npm is holding this package for security reasons. As it's a core Node module, we will not transfer it over to other users. You may safely remove the package from your dependencies.",
"license": "ISC"
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -225,6 +256,21 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": { "node_modules/escape-html": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -304,6 +350,42 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/forwarded": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -392,6 +474,21 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -583,6 +680,12 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.13.0", "version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",

View File

@@ -90,61 +90,80 @@
<div class="editor-layout-modern"> <div class="editor-layout-modern">
<!-- Left Panel: Preset Info & Layers --> <!-- Left Panel: Preset Info & Layers -->
<div class="editor-left-panel"> <div class="editor-left-panel">
<!-- Preset Info --> <!-- Preset -->
<div class="editor-section-compact"> <div class="editor-section-compact editor-preset-section">
<h3 class="section-title-compact">Preset Info</h3> <div class="preset-header">
<h3 class="section-title-compact">Preset</h3>
<div class="preset-header-buttons">
<button class="btn btn-icon" id="editor-new-preset" title="New Preset">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14,2 14,8 20,8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10,9 9,9 8,9"/>
</svg>
</button>
<button class="btn btn-icon" id="editor-save-preset" title="Save Preset">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17,21 17,13 7,13 7,21"/>
<polyline points="7,3 7,8 15,8"/>
</svg>
</button>
<button class="btn btn-icon" id="editor-delete-preset" title="Delete Preset">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3,6 5,6 21,6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>
</button>
<button class="btn btn-icon" id="editor-export-json" title="Export JSON">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7,10 12,15 17,10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
<label class="btn btn-icon" title="Import JSON">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17,8 12,3 7,8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<input type="file" id="editor-import-json" accept=".json" style="display: none;" />
</label>
</div>
</div>
<!-- Preset Name -->
<div class="editor-input-wrapper"> <div class="editor-input-wrapper">
<label>Name</label> <label>Name</label>
<input type="text" id="editor-preset-name" value="New Custom Preset" /> <input type="text" id="editor-preset-name" value="New Custom Preset" />
</div> </div>
<div class="editor-input-wrapper">
<label>Description</label> <!-- Preset Actions -->
<textarea id="editor-preset-desc" rows="3">A custom configurable preset</textarea> <div class="preset-actions">
<div class="preset-actions-row">
<select class="preset-select" id="editor-load-preset">
<option value="">Load saved preset...</option>
</select>
</div>
</div> </div>
</div> </div>
<!-- Add Layer Section -->
<div class="editor-section-compact">
<h3 class="section-title-compact">Add Layer</h3>
<div class="editor-input-wrapper">
<label>Layer Type</label>
<select id="editor-layer-type">
<option value="shape">Shape</option>
<option value="pattern">Pattern</option>
</select>
</div>
<button class="btn btn-primary btn-full-width" id="editor-add-layer"> Add Layer</button>
</div>
<!-- Layers List --> <!-- Layers List -->
<div class="editor-section-compact editor-layers-section"> <div class="editor-section-compact editor-layers-section">
<h3 class="section-title-compact">Layers</h3> <div class="section-title-with-button">
<h3 class="section-title-compact">Layers</h3>
<button class="btn btn-primary btn-small" id="editor-add-layer"></button>
</div>
<div id="editor-layer-list" class="editor-layer-list-expandable"> <div id="editor-layer-list" class="editor-layer-list-expandable">
<p class="editor-empty-state">No layers yet. Click "Add Layer" to get started!</p> <p class="editor-empty-state">No layers yet. Click "Add Layer" to get started!</p>
</div> </div>
</div> </div>
<!-- Manage Section -->
<div class="editor-section-compact">
<h3 class="section-title-compact">Manage</h3>
<div class="editor-actions">
<button class="btn btn-primary" id="editor-new-preset">📄 New</button>
<button class="btn btn-success" id="editor-save-preset">💾 Save</button>
<button class="btn btn-danger" id="editor-delete-preset">🗑️ Delete</button>
</div>
<div class="editor-actions">
<select class="preset-select" id="editor-load-preset">
<option value="">Load saved preset...</option>
</select>
</div>
<div class="editor-actions">
<button class="btn btn-secondary" id="editor-export-json">📤 Export JSON</button>
<label class="btn btn-secondary" style="margin: 0; text-align: center;">
📥 Import JSON
<input type="file" id="editor-import-json" accept=".json" style="display: none;" />
</label>
</div>
</div>
</div> </div>
<!-- Right Panel: Preview & Controls --> <!-- Right Panel: Preview & Controls -->

View File

@@ -67,19 +67,12 @@ class PresetEditor {
setupEventListeners() { setupEventListeners() {
// Preset metadata // Preset metadata
const nameInput = document.getElementById('editor-preset-name'); const nameInput = document.getElementById('editor-preset-name');
const descInput = document.getElementById('editor-preset-desc');
if (nameInput) { if (nameInput) {
nameInput.addEventListener('input', (e) => { nameInput.addEventListener('input', (e) => {
this.configuration.name = e.target.value; this.configuration.name = e.target.value;
}); });
} }
if (descInput) {
descInput.addEventListener('input', (e) => {
this.configuration.description = e.target.value;
});
}
// Add layer button // Add layer button
const addLayerBtn = document.getElementById('editor-add-layer'); const addLayerBtn = document.getElementById('editor-add-layer');
@@ -87,14 +80,6 @@ class PresetEditor {
addLayerBtn.addEventListener('click', () => this.addLayer()); addLayerBtn.addEventListener('click', () => this.addLayer());
} }
// Building block type selector
const typeSelector = document.getElementById('editor-layer-type');
if (typeSelector) {
typeSelector.addEventListener('change', (e) => {
this.showLayerTypeOptions(e.target.value);
});
}
// Save/Load buttons // Save/Load buttons
const saveBtn = document.getElementById('editor-save-preset'); const saveBtn = document.getElementById('editor-save-preset');
if (saveBtn) { if (saveBtn) {
@@ -157,14 +142,8 @@ class PresetEditor {
} }
addLayer() { addLayer() {
const layerType = document.getElementById('editor-layer-type')?.value || 'shape'; // Default to shape layer type
const layer = this.createShapeLayer();
let layer;
if (layerType === 'pattern') {
layer = this.createPatternLayer();
} else {
layer = this.createShapeLayer();
}
this.configuration.layers.push(layer); this.configuration.layers.push(layer);
@@ -464,6 +443,14 @@ class PresetEditor {
} }
renderShapeEditor(container, layer, index) { renderShapeEditor(container, layer, index) {
// Layer type selector
this.addSelect(container, 'Layer Type', layer.type,
['shape', 'pattern'],
(value) => {
this.changeLayerType(index, value);
}
);
// Shape type // Shape type
this.addSelect(container, 'Shape', layer.shape, this.addSelect(container, 'Shape', layer.shape,
['circle', 'rectangle', 'triangle', 'blob', 'point', 'line'], ['circle', 'rectangle', 'triangle', 'blob', 'point', 'line'],
@@ -517,6 +504,14 @@ class PresetEditor {
} }
renderPatternEditor(container, layer, index) { renderPatternEditor(container, layer, index) {
// Layer type selector
this.addSelect(container, 'Layer Type', layer.type,
['shape', 'pattern'],
(value) => {
this.changeLayerType(index, value);
}
);
// Pattern type // Pattern type
this.addSelect(container, 'Pattern', layer.pattern, this.addSelect(container, 'Pattern', layer.pattern,
['trail', 'radial', 'spiral'], ['trail', 'radial', 'spiral'],
@@ -774,6 +769,31 @@ class PresetEditor {
this.renderLayerList(); this.renderLayerList();
} }
changeLayerType(index, newType) {
const layer = this.configuration.layers[index];
if (layer.type === newType) return;
// Create a new layer of the specified type
let newLayer;
if (newType === 'pattern') {
newLayer = this.createPatternLayer();
} else {
newLayer = this.createShapeLayer();
}
// Preserve some properties if they exist
if (layer.intensity !== undefined) {
newLayer.intensity = layer.intensity;
}
// Replace the layer
this.configuration.layers[index] = newLayer;
this.renderLayerList();
this.refreshPreviewIfActive();
}
deleteLayer(index) { deleteLayer(index) {
if (confirm('Delete this layer?')) { if (confirm('Delete this layer?')) {
this.configuration.layers.splice(index, 1); this.configuration.layers.splice(index, 1);
@@ -808,16 +828,11 @@ class PresetEditor {
// Update UI // Update UI
const nameInput = document.getElementById('editor-preset-name'); const nameInput = document.getElementById('editor-preset-name');
const descInput = document.getElementById('editor-preset-desc');
if (nameInput) { if (nameInput) {
nameInput.value = this.configuration.name; nameInput.value = this.configuration.name;
} }
if (descInput) {
descInput.value = this.configuration.description;
}
// Clear and re-render layer list // Clear and re-render layer list
this.renderLayerList(); this.renderLayerList();
@@ -850,7 +865,6 @@ class PresetEditor {
this.configuration = JSON.parse(JSON.stringify(preset)); this.configuration = JSON.parse(JSON.stringify(preset));
document.getElementById('editor-preset-name').value = this.configuration.name; document.getElementById('editor-preset-name').value = this.configuration.name;
document.getElementById('editor-preset-desc').value = this.configuration.description;
this.renderLayerList(); this.renderLayerList();
this.refreshPreviewIfActive(); this.refreshPreviewIfActive();
@@ -868,7 +882,6 @@ class PresetEditor {
this.configuration = this.getDefaultConfiguration(); this.configuration = this.getDefaultConfiguration();
document.getElementById('editor-preset-name').value = this.configuration.name; document.getElementById('editor-preset-name').value = this.configuration.name;
document.getElementById('editor-preset-desc').value = this.configuration.description;
this.loadSavedPresets(); this.loadSavedPresets();
this.renderLayerList(); this.renderLayerList();
@@ -924,7 +937,6 @@ class PresetEditor {
this.configuration = imported; this.configuration = imported;
document.getElementById('editor-preset-name').value = this.configuration.name; document.getElementById('editor-preset-name').value = this.configuration.name;
document.getElementById('editor-preset-desc').value = this.configuration.description;
this.renderLayerList(); this.renderLayerList();
this.refreshPreviewIfActive(); this.refreshPreviewIfActive();

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;
@@ -955,23 +954,23 @@ body {
} }
.btn-primary { .btn-primary {
background: linear-gradient(135deg, var(--accent-primary) 0%, #22c55e 100%); background: linear-gradient(135deg, rgba(74, 222, 128, 0.8) 0%, rgba(34, 197, 94, 0.8) 100%);
color: white; color: white;
border-color: var(--accent-primary); border-color: rgba(74, 222, 128, 0.6);
box-shadow: 0 2px 8px rgba(74, 222, 128, 0.25); box-shadow: 0 2px 8px rgba(74, 222, 128, 0.15);
} }
.btn-primary:hover { .btn-primary:hover {
background: linear-gradient(135deg, #22c55e 0%, var(--accent-primary) 100%); background: linear-gradient(135deg, rgba(34, 197, 94, 0.9) 0%, rgba(74, 222, 128, 0.9) 100%);
border-color: #22c55e; border-color: rgba(34, 197, 94, 0.8);
color: white; color: white;
transform: translateY(-2px); transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(74, 222, 128, 0.35); box-shadow: 0 3px 10px rgba(74, 222, 128, 0.2);
} }
.btn-primary:active { .btn-primary:active {
transform: translateY(0); transform: translateY(0);
box-shadow: 0 2px 6px rgba(74, 222, 128, 0.25); box-shadow: 0 1px 4px rgba(74, 222, 128, 0.15);
} }
.btn-stop { .btn-stop {
@@ -1004,15 +1003,18 @@ body {
} }
.btn-secondary { .btn-secondary {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%); background: linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.04) 100%);
color: var(--text-secondary); color: var(--text-secondary);
border-color: var(--border-secondary); border-color: var(--border-secondary);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
} }
.btn-secondary:hover { .btn-secondary:hover {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%); background: linear-gradient(135deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.08) 100%);
color: var(--text-primary); color: var(--text-primary);
border-color: var(--border-tertiary); border-color: var(--border-tertiary);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
} }
.btn-small { .btn-small {
@@ -1806,6 +1808,17 @@ body {
opacity: 0.85; opacity: 0.85;
} }
.section-title-with-button {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.65rem;
}
.section-title-with-button .section-title-compact {
margin-bottom: 0;
}
.editor-section { .editor-section {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
@@ -2012,6 +2025,132 @@ body {
margin-top: 0.15rem; margin-top: 0.15rem;
} }
.editor-preset-section {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 6px;
padding: 0.75rem;
margin-bottom: 0.75rem;
}
.preset-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.75rem;
}
.preset-actions-row {
display: flex;
gap: 0.375rem;
align-items: center;
}
.preset-actions-row:first-child {
justify-content: stretch;
}
.preset-actions-row .preset-select {
flex: 1;
margin: 0;
}
.editor-preset-section .editor-input-wrapper {
margin-bottom: 0.5rem;
}
.editor-preset-section .editor-input-wrapper input {
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
min-height: 2rem;
}
.editor-preset-section .section-title-compact {
margin-bottom: 0.5rem;
font-size: 0.8rem;
}
.preset-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.preset-header .section-title-compact {
margin-bottom: 0;
}
.preset-header-buttons {
display: flex;
gap: 0.25rem;
}
.btn-icon {
padding: 0.5rem;
min-width: 2.5rem;
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-tertiary);
border: 1px solid var(--border-secondary);
border-radius: 6px;
color: var(--text-secondary);
transition: all 0.2s ease;
cursor: pointer;
position: relative;
overflow: hidden;
}
.btn-icon:hover {
background: var(--bg-hover);
border-color: var(--border-hover);
color: var(--text-primary);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.btn-icon:active {
transform: translateY(0);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
.btn-icon svg {
transition: all 0.2s ease;
}
.btn-icon:hover svg {
transform: scale(1.05);
}
.btn-icon:active svg {
transform: scale(0.95);
}
/* Icon button color variations */
.btn-icon[id="editor-new-preset"]:hover {
border-color: rgba(74, 222, 128, 0.4);
color: var(--accent-primary);
}
.btn-icon[id="editor-save-preset"]:hover {
border-color: rgba(76, 175, 80, 0.4);
color: var(--accent-success);
}
.btn-icon[id="editor-delete-preset"]:hover {
border-color: rgba(248, 113, 113, 0.4);
color: var(--accent-error);
}
.btn-icon[id="editor-export-json"]:hover,
.btn-icon[for="editor-import-json"]:hover {
border-color: rgba(96, 165, 250, 0.4);
color: var(--accent-secondary);
}
.editor-layer-buttons { .editor-layer-buttons {
display: flex; display: flex;
gap: 0.3rem; gap: 0.3rem;
@@ -2103,21 +2242,31 @@ body {
/* Button Variants */ /* Button Variants */
.btn-success { .btn-success {
background: linear-gradient(135deg, var(--accent-success) 0%, #45a049 100%); background: linear-gradient(135deg, rgba(76, 175, 80, 0.8) 0%, rgba(69, 160, 73, 0.8) 100%);
color: white; color: white;
border-color: rgba(76, 175, 80, 0.6);
box-shadow: 0 2px 8px rgba(76, 175, 80, 0.15);
} }
.btn-success:hover { .btn-success:hover {
background: linear-gradient(135deg, #45a049 0%, #3d8b40 100%); background: linear-gradient(135deg, rgba(69, 160, 73, 0.9) 0%, rgba(61, 139, 64, 0.9) 100%);
border-color: rgba(69, 160, 73, 0.8);
transform: translateY(-1px);
box-shadow: 0 3px 10px rgba(76, 175, 80, 0.2);
} }
.btn-danger { .btn-danger {
background: linear-gradient(135deg, var(--accent-error) 0%, #dc2626 100%); background: linear-gradient(135deg, rgba(248, 113, 113, 0.8) 0%, rgba(220, 38, 38, 0.8) 100%);
color: white; color: white;
border-color: rgba(248, 113, 113, 0.6);
box-shadow: 0 2px 8px rgba(248, 113, 113, 0.15);
} }
.btn-danger:hover { .btn-danger:hover {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%); background: linear-gradient(135deg, rgba(220, 38, 38, 0.9) 0%, rgba(185, 28, 28, 0.9) 100%);
border-color: rgba(220, 38, 38, 0.8);
transform: translateY(-1px);
box-shadow: 0 3px 10px rgba(248, 113, 113, 0.2);
} }
/* Notifications */ /* Notifications */

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

@@ -0,0 +1,202 @@
// Gateway Client - Communicates with spore-gateway for node discovery
const WebSocket = require('ws');
class GatewayClient {
constructor(options = {}) {
this.gatewayUrl = options.gatewayUrl || 'http://localhost:3001';
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.ws = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
this.reconnectDelay = 2000;
this.reconnectTimer = null;
}
start() {
if (this.isRunning) {
return;
}
this.isRunning = true;
console.log(`Starting Gateway client, connecting to ${this.gatewayUrl}`);
this.connectWebSocket();
}
stop() {
if (!this.isRunning) {
return;
}
this.isRunning = false;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.nodes.clear();
console.log('Gateway client stopped');
}
connectWebSocket() {
try {
// Convert http:// to ws:// for WebSocket
const wsUrl = this.gatewayUrl.replace('http://', 'ws://').replace('https://', 'wss://') + '/ws';
console.log(`Connecting to WebSocket: ${wsUrl}`);
this.ws = new WebSocket(wsUrl);
this.ws.on('open', () => {
console.log('WebSocket connected to gateway');
this.reconnectAttempts = 0;
});
this.ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
this.handleWebSocketMessage(message);
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
});
this.ws.on('close', (code, reason) => {
console.log(`WebSocket connection closed: ${code} ${reason}`);
if (this.isRunning) {
this.scheduleReconnect();
}
});
this.ws.on('error', (error) => {
console.error('WebSocket error:', error.message);
});
} catch (error) {
console.error('Failed to create WebSocket connection:', error);
this.scheduleReconnect();
}
}
handleWebSocketMessage(message) {
if (message.topic === 'cluster/update') {
this.handleClusterUpdate(message.members, message.primaryNode, message.totalNodes);
} else if (message.topic === 'node/discovery') {
this.handleNodeDiscovery(message.action, message.nodeIp);
}
}
handleClusterUpdate(members, primaryNode, totalNodes) {
const newNodes = new Map();
if (members && Array.isArray(members)) {
members.forEach(node => {
// Filter for nodes with specified app label (if filtering is enabled)
if (this.filterAppLabel && !this.hasAppLabel(node, this.filterAppLabel)) {
return;
}
const nodeIp = node.ip || node.IP;
newNodes.set(nodeIp, {
lastSeen: Date.now(),
status: node.status || node.Status || 'active',
hostname: node.hostname || node.Hostname || nodeIp,
port: node.port || node.Port || 4210,
isPrimary: (primaryNode === nodeIp),
labels: node.labels || node.Labels
});
});
}
// 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);
} else if (existingNode.hostname !== nodeInfo.hostname) {
console.log(`Node hostname updated: ${ip} -> ${nodeInfo.hostname}`);
this.nodes.set(ip, nodeInfo);
} else {
// Update status and last seen
existingNode.status = nodeInfo.status;
existingNode.lastSeen = nodeInfo.lastSeen;
existingNode.isPrimary = nodeInfo.isPrimary;
}
}
// 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);
}
}
}
handleNodeDiscovery(action, nodeIp) {
if (action === 'discovered') {
// Node was discovered - actual data will come via cluster/update
console.log(`Node discovered event: ${nodeIp}`);
} else if (action === 'removed') {
console.log(`Node removed event: ${nodeIp}`);
this.nodes.delete(nodeIp);
}
}
scheduleReconnect() {
if (!this.isRunning) {
return;
}
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max WebSocket reconnection attempts reached');
return;
}
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
console.log(`Attempting to reconnect WebSocket in ${delay}ms (attempt ${this.reconnectAttempts})`);
this.reconnectTimer = setTimeout(() => {
this.connectWebSocket();
}, delay);
}
getNodes() {
return Array.from(this.nodes.entries()).map(([ip, node]) => ({
ip,
hostname: node.hostname || ip,
port: node.port,
status: node.status,
isPrimary: node.isPrimary,
...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
@@ -462,7 +459,10 @@ class LEDLabServer {
const frameData = stream.preset.generateFrame(); const frameData = stream.preset.generateFrame();
if (frameData) { if (frameData) {
// Send to specific node // Send to specific node
this.udpDiscovery.sendToNode(nodeIp, frameData); // frameData format: "RAW:FF0000FF0000..." (RAW prefix + hex pixel data)
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({
@@ -475,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() {
@@ -483,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,
@@ -603,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}`);
}); });
} }
@@ -614,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();
@@ -645,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,101 +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;
}
// 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
}));
return nodes;
}
getNodeCount() {
return this.nodes.size;
} }
sendToNode(nodeIp, message) { sendToNode(nodeIp, message) {
@@ -170,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) {
@@ -189,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;