Compare commits
35 Commits
refactorin
...
7a7400422e
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a7400422e | |||
| a45871625e | |||
| 52f9098c1b | |||
| 0fcebc0459 | |||
| d07c1f40de | |||
| 3077f8685b | |||
| 3ff5df5a6f | |||
| be1a2f1e21 | |||
| 1383f6d32f | |||
| d1fb5fc96e | |||
| f78dd8b843 | |||
| f3d99b174f | |||
| 99e7ed4809 | |||
| dbb3b5bfd3 | |||
| 2aa887a037 | |||
| 49fe0dabf4 | |||
| c0bf16fecb | |||
| af46b5c2f4 | |||
| 7a37901fb2 | |||
| 3cc5405292 | |||
| f0df84dc87 | |||
| 950142bf7f | |||
| b9b91d71b5 | |||
| 0e6adc999f | |||
| f4ccb1c7ef | |||
| 8da9f77441 | |||
| cabf857bbd | |||
| 2bb0742850 | |||
| 69bc3fc829 | |||
| eaeb9bbea8 | |||
| 096cf12704 | |||
| 356ec3d381 | |||
| 51bd7bd909 | |||
| 921eec3848 | |||
| 921e2c7152 |
58
README.md
58
README.md
@@ -9,6 +9,8 @@ SPORE is a cluster engine for ESP8266 microcontrollers that provides automatic n
|
||||
- [Features](#features)
|
||||
- [Supported Hardware](#supported-hardware)
|
||||
- [Architecture](#architecture)
|
||||
- [Cluster Broadcast](#cluster-broadcast)
|
||||
- [Streaming API](#streaming-api)
|
||||
- [API Reference](#api-reference)
|
||||
- [Configuration](#configuration)
|
||||
- [Development](#development)
|
||||
@@ -26,6 +28,8 @@ SPORE is a cluster engine for ESP8266 microcontrollers that provides automatic n
|
||||
- **Service Registry**: Dynamic API endpoint discovery and registration
|
||||
- **Health Monitoring**: Real-time node status tracking with resource monitoring
|
||||
- **Event System**: Local and cluster-wide event publishing/subscription
|
||||
- **Cluster Broadcast**: Centralized UDP broadcast of events (CLUSTER_EVENT)
|
||||
- **Streaming API**: WebSocket bridge for real-time event send/receive
|
||||
- **Over-The-Air Updates**: Seamless firmware updates across the cluster
|
||||
- **REST API**: HTTP-based cluster management and monitoring
|
||||
- **Capability Discovery**: Automatic API endpoint and service capability detection
|
||||
@@ -89,12 +93,12 @@ void setup() {
|
||||
spore.setup();
|
||||
|
||||
// Create and register custom services
|
||||
RelayService* relayService = new RelayService(spore.getTaskManager(), 2);
|
||||
spore.addService(relayService);
|
||||
RelayService* relayService = new RelayService(spore.getContext(), spore.getTaskManager(), 2);
|
||||
spore.registerService(relayService);
|
||||
|
||||
// Or using smart pointers
|
||||
auto sensorService = std::make_shared<SensorService>();
|
||||
spore.addService(sensorService);
|
||||
auto sensorService = std::make_shared<SensorService>(spore.getContext(), spore.getTaskManager());
|
||||
spore.registerService(sensorService);
|
||||
|
||||
// Start the API server and complete initialization
|
||||
spore.begin();
|
||||
@@ -103,6 +107,52 @@ void setup() {
|
||||
|
||||
**Examples:** See [`examples/base/`](./examples/base/) for basic usage and [`examples/relay/`](./examples/relay/) for custom service integration.
|
||||
|
||||
## Cluster Broadcast
|
||||
|
||||
Broadcast an event to all peers using the centralized core broadcaster. Services never touch UDP directly; instead they fire a local event that the core transmits as a `CLUSTER_EVENT`.
|
||||
|
||||
Usage:
|
||||
|
||||
```cpp
|
||||
// 1) Apply locally via the same event your service already handles
|
||||
JsonDocument payload;
|
||||
payload["pattern"] = "rainbow_cycle";
|
||||
payload["brightness"] = 100;
|
||||
String payloadStr; serializeJson(payload, payloadStr);
|
||||
ctx.fire("api/neopattern", &payloadStr);
|
||||
|
||||
// 2) Broadcast to peers via the core
|
||||
JsonDocument envelope;
|
||||
envelope["event"] = "api/neopattern";
|
||||
envelope["data"] = payloadStr; // JSON string
|
||||
String eventJson; serializeJson(envelope, eventJson);
|
||||
ctx.fire("cluster/broadcast", &eventJson);
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The core sends subnet-directed broadcasts (e.g., 192.168.1.255) for reliability.
|
||||
- Peers receive `CLUSTER_EVENT` and forward to local subscribers with `ctx.fire(event, data)`.
|
||||
- `data` can be a JSON string or nested JSON; receivers handle both.
|
||||
|
||||
📖 See the dedicated guide: [`docs/ClusterBroadcast.md`](./docs/ClusterBroadcast.md)
|
||||
|
||||
## Streaming API
|
||||
|
||||
Real-time event bridge available at `/ws` using WebSocket.
|
||||
|
||||
- Send JSON `{ event, payload }` to dispatch events via `ctx.fire`.
|
||||
- Receive all local events as `{ event, payload }`.
|
||||
|
||||
Examples:
|
||||
```json
|
||||
{ "event": "api/neopattern/color", "payload": { "color": "#FF0000", "brightness": 128 } }
|
||||
```
|
||||
```json
|
||||
{ "event": "cluster/broadcast", "payload": { "event": "api/neopattern/color", "data": { "color": "#00FF00" } } }
|
||||
```
|
||||
|
||||
📖 See the dedicated guide: [`docs/StreamingAPI.md`](./docs/StreamingAPI.md)
|
||||
|
||||
## API Reference
|
||||
|
||||
The system provides a comprehensive RESTful API for monitoring and controlling the embedded device. All endpoints return JSON responses and support standard HTTP status codes.
|
||||
|
||||
60
docs/API.md
60
docs/API.md
@@ -15,12 +15,18 @@ The SPORE system provides a comprehensive RESTful API for monitoring and control
|
||||
|
||||
| Endpoint | Method | Description | Response |
|
||||
|----------|--------|-------------|----------|
|
||||
| `/api/node/status` | GET | System resource information and API endpoint registry | System metrics and API catalog |
|
||||
| `/api/node/status` | GET | System resource information | System metrics |
|
||||
| `/api/node/endpoints` | GET | API endpoints and parameters | Detailed endpoint specifications |
|
||||
| `/api/cluster/members` | GET | Cluster membership and node health information | Cluster topology and health status |
|
||||
| `/api/node/update` | POST | Handle firmware updates via OTA | Update progress and status |
|
||||
| `/api/node/restart` | POST | Trigger system restart | Restart confirmation |
|
||||
|
||||
### Monitoring API
|
||||
|
||||
| Endpoint | Method | Description | Response |
|
||||
|----------|--------|-------------|----------|
|
||||
| `/api/monitoring/resources` | GET | CPU, memory, filesystem, and uptime | System resource metrics |
|
||||
|
||||
### Network Management API
|
||||
|
||||
| Endpoint | Method | Description | Response |
|
||||
@@ -140,7 +146,7 @@ Controls the execution state of individual tasks. Supports enabling, disabling,
|
||||
|
||||
#### GET /api/node/status
|
||||
|
||||
Returns comprehensive system resource information including memory usage, chip details, and a registry of all available API endpoints.
|
||||
Returns comprehensive system resource information including memory usage and chip details. For a list of available API endpoints, use `/api/node/endpoints`.
|
||||
|
||||
**Response Fields:**
|
||||
- `freeHeap`: Available RAM in bytes
|
||||
@@ -168,7 +174,7 @@ Returns comprehensive system resource information including memory usage, chip d
|
||||
|
||||
#### GET /api/node/endpoints
|
||||
|
||||
Returns detailed information about all available API endpoints, including their parameters, types, and validation rules.
|
||||
Returns detailed information about all available API endpoints, including their parameters, types, and validation rules. Methods are returned as strings (e.g., "GET", "POST").
|
||||
|
||||
**Response Fields:**
|
||||
- `endpoints[]`: Array of endpoint capability objects
|
||||
@@ -236,6 +242,54 @@ Initiates an over-the-air firmware update. The firmware file should be uploaded
|
||||
|
||||
Triggers a system restart. The response will be sent before the restart occurs.
|
||||
|
||||
### Monitoring
|
||||
|
||||
#### GET /api/monitoring/resources
|
||||
|
||||
Returns real-time system resource metrics.
|
||||
|
||||
Response Fields:
|
||||
- `cpu.current_usage`: Current CPU usage percent
|
||||
- `cpu.average_usage`: Average CPU usage percent
|
||||
- `cpu.max_usage`: Max observed CPU usage
|
||||
- `cpu.min_usage`: Min observed CPU usage
|
||||
- `cpu.measurement_count`: Number of measurements
|
||||
- `cpu.is_measuring`: Whether measurement is active
|
||||
- `memory.free_heap`: Free heap bytes
|
||||
- `memory.total_heap`: Total heap bytes (approximate)
|
||||
- `memory.heap_fragmentation`: Fragmentation percent (0 on ESP8266)
|
||||
- `filesystem.total_bytes`: LittleFS total bytes
|
||||
- `filesystem.used_bytes`: Used bytes
|
||||
- `filesystem.free_bytes`: Free bytes
|
||||
- `filesystem.usage_percent`: Usage percent
|
||||
- `system.uptime_ms`: Uptime in milliseconds
|
||||
Example Response:
|
||||
```json
|
||||
{
|
||||
"cpu": {
|
||||
"current_usage": 3.5,
|
||||
"average_usage": 2.1,
|
||||
"max_usage": 15.2,
|
||||
"min_usage": 0.0,
|
||||
"measurement_count": 120,
|
||||
"is_measuring": true
|
||||
},
|
||||
"memory": {
|
||||
"free_heap": 48748,
|
||||
"total_heap": 81920,
|
||||
"heap_fragmentation": 0
|
||||
},
|
||||
"filesystem": {
|
||||
"total_bytes": 65536,
|
||||
"used_bytes": 10240,
|
||||
"free_bytes": 55296,
|
||||
"usage_percent": 15.6
|
||||
},
|
||||
"system": {
|
||||
"uptime_ms": 123456
|
||||
}
|
||||
}
|
||||
```
|
||||
### Network Management
|
||||
|
||||
#### GET /api/network/status
|
||||
|
||||
@@ -21,13 +21,15 @@ The system architecture consists of several key components working together:
|
||||
|
||||
### API Server
|
||||
- **HTTP API Server**: RESTful API for cluster management
|
||||
- **Dynamic Endpoint Registration**: Automatic API endpoint discovery
|
||||
- **Dynamic Endpoint Registration**: Services register endpoints via `registerEndpoints(ApiServer&)`
|
||||
- **Service Registry**: Track available services across the cluster
|
||||
- **Service Lifecycle**: Services register both endpoints and tasks through unified interface
|
||||
|
||||
### Task Scheduler
|
||||
- **Cooperative Multitasking**: Background task management system
|
||||
- **Task Lifecycle Management**: Automatic task execution and monitoring
|
||||
- **Resource Optimization**: Efficient task scheduling and execution
|
||||
- **Cooperative Multitasking**: Background task management system (`TaskManager`)
|
||||
- **Service Task Registration**: Services register tasks via `registerTasks(TaskManager&)`
|
||||
- **Task Lifecycle Management**: Enable/disable tasks and set intervals at runtime
|
||||
- **Execution Model**: Tasks run in `Spore::loop()` when their interval elapses
|
||||
|
||||
### Node Context
|
||||
- **Central Context**: Shared resources and configuration
|
||||
@@ -40,27 +42,75 @@ The cluster uses a UDP-based discovery protocol for automatic node detection:
|
||||
|
||||
### Discovery Process
|
||||
|
||||
1. **Discovery Broadcast**: Nodes periodically send UDP packets on port 4210
|
||||
2. **Response Handling**: Nodes respond with their hostname and IP address
|
||||
3. **Member Management**: Discovered nodes are automatically added to the cluster
|
||||
4. **Health Monitoring**: Continuous status checking via HTTP API calls
|
||||
1. **Discovery Broadcast**: Nodes periodically send UDP packets on port `udp_port` (default 4210)
|
||||
2. **Response Handling**: Nodes respond with `CLUSTER_RESPONSE:<hostname>`
|
||||
3. **Member Management**: Discovered nodes are added/updated in the cluster
|
||||
4. **Node Info via UDP**: Heartbeat triggers peers to send `CLUSTER_NODE_INFO:<hostname>:<json>`
|
||||
|
||||
### Protocol Details
|
||||
|
||||
- **UDP Port**: 4210 (configurable)
|
||||
- **UDP Port**: 4210 (configurable via `Config.udp_port`)
|
||||
- **Discovery Message**: `CLUSTER_DISCOVERY`
|
||||
- **Response Message**: `CLUSTER_RESPONSE`
|
||||
- **Heartbeat Message**: `CLUSTER_HEARTBEAT`
|
||||
- **Node Info Message**: `CLUSTER_NODE_INFO:<hostname>:<json>`
|
||||
- **Broadcast Address**: 255.255.255.255
|
||||
- **Discovery Interval**: 1 second (configurable)
|
||||
- **Listen Interval**: 100ms (configurable)
|
||||
- **Discovery Interval**: `Config.discovery_interval_ms` (default 1000 ms)
|
||||
- **Listen Interval**: `Config.cluster_listen_interval_ms` (default 10 ms)
|
||||
- **Heartbeat Interval**: `Config.heartbeat_interval_ms` (default 5000 ms)
|
||||
|
||||
### Message Formats
|
||||
|
||||
- **Discovery**: `CLUSTER_DISCOVERY`
|
||||
- Sender: any node, broadcast to 255.255.255.255:`udp_port`
|
||||
- Purpose: announce presence and solicit peer identification
|
||||
- **Response**: `CLUSTER_RESPONSE:<hostname>`
|
||||
- Sender: node receiving a discovery; unicast to requester IP
|
||||
- Purpose: provide hostname so requester can register/update member
|
||||
- **Heartbeat**: `CLUSTER_HEARTBEAT:<hostname>`
|
||||
- Sender: each node, broadcast to 255.255.255.255:`udp_port` on interval
|
||||
- Purpose: prompt peers to reply with their node info and keep liveness
|
||||
- **Node Info**: `CLUSTER_NODE_INFO:<hostname>:<json>`
|
||||
- Sender: node receiving a heartbeat; unicast to heartbeat sender IP
|
||||
- JSON fields: freeHeap, chipId, sdkVersion, cpuFreqMHz, flashChipSize, optional labels
|
||||
|
||||
### Discovery Flow
|
||||
|
||||
1. **Sender broadcasts** `CLUSTER_DISCOVERY`
|
||||
2. **Each receiver responds** with `CLUSTER_RESPONSE:<hostname>` to the sender IP
|
||||
3. **Sender registers/updates** the node using hostname and source IP
|
||||
|
||||
### Heartbeat Flow
|
||||
|
||||
1. **A node broadcasts** `CLUSTER_HEARTBEAT:<hostname>`
|
||||
2. **Each receiver replies** with `CLUSTER_NODE_INFO:<hostname>:<json>` to the heartbeat sender IP
|
||||
3. **The sender**:
|
||||
- Ensures the node exists or creates it with `hostname` and sender IP
|
||||
- Parses JSON and updates resources, labels, `status = ACTIVE`, `lastSeen = now`
|
||||
- Sets `latency = now - lastHeartbeatSentAt` (per-node, measured at heartbeat origin)
|
||||
|
||||
### Listener Behavior
|
||||
|
||||
The `cluster_listen` task parses one UDP packet per run and dispatches by prefix to:
|
||||
- **Discovery** → send `CLUSTER_RESPONSE`
|
||||
- **Heartbeat** → send `CLUSTER_NODE_INFO` JSON
|
||||
- **Response** → add/update node using provided hostname and source IP
|
||||
- **Node Info** → update resources/status/labels and record latency
|
||||
|
||||
### Timing and Intervals
|
||||
|
||||
- **UDP Port**: `Config.udp_port` (default 4210)
|
||||
- **Discovery Interval**: `Config.discovery_interval_ms` (default 1000 ms)
|
||||
- **Listen Interval**: `Config.cluster_listen_interval_ms` (default 10 ms)
|
||||
- **Heartbeat Interval**: `Config.heartbeat_interval_ms` (default 5000 ms)
|
||||
|
||||
### Node Status Categories
|
||||
|
||||
Nodes are automatically categorized by their activity:
|
||||
|
||||
- **ACTIVE**: Responding within 10 seconds
|
||||
- **INACTIVE**: No response for 10-60 seconds
|
||||
- **DEAD**: No response for over 60 seconds
|
||||
- **ACTIVE**: lastSeen < `node_inactive_threshold_ms` (default 10s)
|
||||
- **INACTIVE**: < `node_dead_threshold_ms` (default 120s)
|
||||
- **DEAD**: ≥ `node_dead_threshold_ms`
|
||||
|
||||
## Task Scheduling System
|
||||
|
||||
@@ -68,14 +118,14 @@ The system runs several background tasks at different intervals:
|
||||
|
||||
### Core System Tasks
|
||||
|
||||
| Task | Interval | Purpose |
|
||||
|------|----------|---------|
|
||||
| **Discovery Send** | 1 second | Send UDP discovery packets |
|
||||
| **Discovery Listen** | 100ms | Listen for discovery responses |
|
||||
| **Status Updates** | 1 second | Monitor cluster member health |
|
||||
| **Heartbeat** | 2 seconds | Maintain cluster connectivity |
|
||||
| **Member Info** | 10 seconds | Update detailed node information |
|
||||
| **Debug Output** | 5 seconds | Print cluster status |
|
||||
| Task | Interval (default) | Purpose |
|
||||
|------|--------------------|---------|
|
||||
| `cluster_discovery` | 1000 ms | Send UDP discovery packets |
|
||||
| `cluster_listen` | 10 ms | Listen for discovery/heartbeat/node-info |
|
||||
| `status_update` | 1000 ms | Update node status categories, purge dead |
|
||||
| `heartbeat` | 5000 ms | Broadcast heartbeat and update local resources |
|
||||
| `cluster_update_members_info` | 10000 ms | Reserved; no-op (info via UDP) |
|
||||
| `print_members` | 5000 ms | Log current member list |
|
||||
|
||||
### Task Management Features
|
||||
|
||||
@@ -112,10 +162,7 @@ ctx.fire("cluster_updated", &clusterData);
|
||||
|
||||
### Available Events
|
||||
|
||||
- **`node_discovered`**: New node added to cluster
|
||||
- **`cluster_updated`**: Cluster membership changed
|
||||
- **`resource_update`**: Node resources updated
|
||||
- **`health_check`**: Node health status changed
|
||||
- **`node_discovered`**: New node added or local node refreshed
|
||||
|
||||
## Resource Monitoring
|
||||
|
||||
@@ -155,10 +202,8 @@ The system includes automatic WiFi fallback for robust operation:
|
||||
|
||||
### Configuration
|
||||
|
||||
- **SSID Format**: `SPORE_<MAC_LAST_4>`
|
||||
- **Password**: Configurable fallback password
|
||||
- **IP Range**: 192.168.4.x subnet
|
||||
- **Gateway**: 192.168.4.1
|
||||
- **Hostname**: Derived from MAC (`esp-<mac>`) and assigned to `ctx.hostname`
|
||||
- **AP Mode**: If STA connection fails, device switches to AP mode with configured SSID/password
|
||||
|
||||
## Cluster Topology
|
||||
|
||||
@@ -170,32 +215,30 @@ The system includes automatic WiFi fallback for robust operation:
|
||||
|
||||
### Network Architecture
|
||||
|
||||
- **Mesh-like Structure**: Nodes can communicate with each other
|
||||
- **Dynamic Routing**: Automatic path discovery between nodes
|
||||
- **Load Distribution**: Tasks distributed across available nodes
|
||||
- **Fault Tolerance**: Automatic failover and recovery
|
||||
- UDP broadcast-based discovery and heartbeats on local subnet
|
||||
- Optional HTTP polling (disabled by default; node info exchanged via UDP)
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Node Discovery
|
||||
1. **UDP Broadcast**: Nodes broadcast discovery packets on port 4210
|
||||
2. **UDP Response**: Receiving nodes responds with hostname
|
||||
2. **UDP Response**: Receiving nodes respond with hostname
|
||||
3. **Registration**: Discovered nodes are added to local cluster member list
|
||||
|
||||
### Health Monitoring
|
||||
1. **Periodic Checks**: Cluster manager polls member nodes every 1 second
|
||||
2. **Status Collection**: Each node returns resource usage and health metrics
|
||||
1. **Periodic Checks**: Cluster manager updates node status categories
|
||||
2. **Status Collection**: Each node updates resources via UDP node-info messages
|
||||
|
||||
### Task Management
|
||||
1. **Scheduling**: TaskScheduler executes registered tasks at configured intervals
|
||||
2. **Execution**: Tasks run cooperatively, yielding control to other tasks
|
||||
3. **Monitoring**: Task status and results are exposed via REST API endpoints
|
||||
1. **Scheduling**: `TaskManager` executes registered tasks at configured intervals
|
||||
2. **Execution**: Tasks run cooperatively in the main loop without preemption
|
||||
3. **Monitoring**: Task status is exposed via REST (`/api/tasks/status`)
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
### Memory Usage
|
||||
|
||||
- **Base System**: ~15-20KB RAM
|
||||
- **Base System**: ~15-20KB RAM (device dependent)
|
||||
- **Per Task**: ~100-200 bytes per task
|
||||
- **Cluster Members**: ~50-100 bytes per member
|
||||
- **API Endpoints**: ~20-30 bytes per endpoint
|
||||
@@ -219,7 +262,7 @@ The system includes automatic WiFi fallback for robust operation:
|
||||
### Current Implementation
|
||||
|
||||
- **Network Access**: Local network only (no internet exposure)
|
||||
- **Authentication**: None currently implemented
|
||||
- **Authentication**: None currently implemented; LAN-only access assumed
|
||||
- **Data Validation**: Basic input validation
|
||||
- **Resource Limits**: Memory and processing constraints
|
||||
|
||||
|
||||
91
docs/ClusterBroadcast.md
Normal file
91
docs/ClusterBroadcast.md
Normal file
@@ -0,0 +1,91 @@
|
||||
## Cluster Broadcast (CLUSTER_EVENT)
|
||||
|
||||
### Overview
|
||||
|
||||
Spore supports cluster-wide event broadcasting via UDP. Services publish a local event, and the core broadcasts it to peers as a `CLUSTER_EVENT`. Peers receive the event and forward it to local subscribers through the internal event bus.
|
||||
|
||||
- **Local trigger**: `ctx.fire("cluster/broadcast", eventJson)`
|
||||
- **UDP message**: `CLUSTER_EVENT:{json}`
|
||||
- **Receiver action**: Parses `{json}` and calls `ctx.fire(event, data)`
|
||||
|
||||
This centralizes network broadcast in core, so services never touch UDP directly.
|
||||
|
||||
### Message format
|
||||
|
||||
- UDP payload prefix: `CLUSTER_EVENT:`
|
||||
- JSON body:
|
||||
```json
|
||||
{
|
||||
"event": "<event-name>",
|
||||
"data": "<json-string>" // or an inline JSON object/array
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The receiver accepts `data` as either a JSON string or a nested JSON object/array. Nested JSON is serialized back to a string before firing the local event.
|
||||
- Keep payloads small (UDP, default buffer 512 bytes).
|
||||
|
||||
### Core responsibilities
|
||||
|
||||
- `ClusterManager` registers a centralized handler:
|
||||
- Subscribes to `cluster/broadcast` to send the provided event JSON over UDP broadcast.
|
||||
- Listens for incoming UDP `CLUSTER_EVENT` messages and forwards them to local subscribers via `ctx.fire(event, data)`.
|
||||
- Broadcast target uses subnet-directed broadcast (e.g., `192.168.1.255`) for better reliability. Both nodes must share the same `udp_port`.
|
||||
|
||||
### Service responsibilities
|
||||
|
||||
Services send and receive events using the local event bus.
|
||||
|
||||
1) Subscribe to an event name and apply state from `data`:
|
||||
```cpp
|
||||
ctx.on("api/neopattern", [this](void* dataPtr) {
|
||||
String* jsonStr = static_cast<String*>(dataPtr);
|
||||
if (!jsonStr) return;
|
||||
JsonDocument doc;
|
||||
if (deserializeJson(doc, *jsonStr)) return;
|
||||
JsonObject obj = doc.as<JsonObject>();
|
||||
// Parse and apply fields from obj
|
||||
});
|
||||
```
|
||||
|
||||
2) Build a control payload and update locally via the same event:
|
||||
```cpp
|
||||
JsonDocument payload;
|
||||
payload["pattern"] = "rainbow_cycle"; // example
|
||||
payload["brightness"] = 100;
|
||||
String payloadStr; serializeJson(payload, payloadStr);
|
||||
ctx.fire("api/neopattern", &payloadStr);
|
||||
``;
|
||||
|
||||
3) Broadcast to peers by delegating to core:
|
||||
```cpp
|
||||
JsonDocument envelope;
|
||||
envelope["event"] = "api/neopattern";
|
||||
envelope["data"] = payloadStr; // JSON string
|
||||
String eventJson; serializeJson(envelope, eventJson);
|
||||
ctx.fire("cluster/broadcast", &eventJson);
|
||||
```
|
||||
|
||||
With this flow, services have a single codepath for applying state (the event handler). Broadcasting simply reuses the same payload.
|
||||
|
||||
### Logging
|
||||
|
||||
- Core logs source IP, payload length, and event name for received `CLUSTER_EVENT`s.
|
||||
- Services can log when submitting `cluster/broadcast` and when applying control events.
|
||||
|
||||
### Networking considerations
|
||||
|
||||
- Ensure all nodes:
|
||||
- Listen on the same `udp_port`.
|
||||
- Are in the same subnet (for subnet-directed broadcast).
|
||||
- Some networks may block global broadcast (`255.255.255.255`). Subnet-directed broadcast is used by default.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- If peers do not react:
|
||||
- Confirm logs show `CLUSTER_EVENT raw from <ip>` on the receiver.
|
||||
- Verify UDP port alignment and WiFi connection/subnet.
|
||||
- Check payload size (<512 bytes by default) and JSON validity.
|
||||
- Ensure the service subscribed to the correct `event` name and handles `data`.
|
||||
|
||||
|
||||
@@ -20,57 +20,99 @@
|
||||
|
||||
```
|
||||
spore/
|
||||
├── src/ # Source code
|
||||
│ ├── main.cpp # Main application entry point
|
||||
│ ├── ApiServer.cpp # HTTP API server implementation
|
||||
│ ├── ClusterManager.cpp # Cluster management logic
|
||||
│ ├── NetworkManager.cpp # WiFi and network handling
|
||||
│ ├── TaskManager.cpp # Background task management
|
||||
│ └── NodeContext.cpp # Central context and events
|
||||
├── src/ # Source code (framework under src/spore)
|
||||
│ └── spore/
|
||||
│ ├── Spore.cpp # Framework lifecycle (setup/begin/loop)
|
||||
│ ├── core/ # Core components
|
||||
│ │ ├── ApiServer.cpp # HTTP API server implementation
|
||||
│ │ ├── ClusterManager.cpp # Cluster management logic
|
||||
│ │ ├── NetworkManager.cpp # WiFi and network handling
|
||||
│ │ ├── TaskManager.cpp # Background task management
|
||||
│ │ └── NodeContext.cpp # Central context and events
|
||||
│ ├── services/ # Built-in services (implement Service interface)
|
||||
│ │ ├── NodeService.cpp # registerEndpoints() + registerTasks()
|
||||
│ │ ├── NetworkService.cpp # registerEndpoints() + registerTasks()
|
||||
│ │ ├── ClusterService.cpp # registerEndpoints() + registerTasks()
|
||||
│ │ ├── TaskService.cpp # registerEndpoints() + registerTasks()
|
||||
│ │ ├── StaticFileService.cpp # registerEndpoints() + registerTasks()
|
||||
│ │ └── MonitoringService.cpp # registerEndpoints() + registerTasks()
|
||||
│ └── types/ # Shared types
|
||||
├── include/ # Header files
|
||||
├── lib/ # Library files
|
||||
├── examples/ # Example apps per env (base, relay, neopattern)
|
||||
├── docs/ # Documentation
|
||||
├── api/ # OpenAPI specification
|
||||
├── examples/ # Example code
|
||||
├── test/ # Test files
|
||||
├── platformio.ini # PlatformIO configuration
|
||||
└── ctl.sh # Build and deployment scripts
|
||||
├── platformio.ini # PlatformIO configuration
|
||||
└── ctl.sh # Build and deployment scripts
|
||||
```
|
||||
|
||||
## PlatformIO Configuration
|
||||
|
||||
### Framework and Board
|
||||
|
||||
The project uses PlatformIO with the following configuration:
|
||||
The project uses PlatformIO with the following configuration (excerpt):
|
||||
|
||||
```ini
|
||||
[env:esp01_1m]
|
||||
[platformio]
|
||||
default_envs = base
|
||||
src_dir = .
|
||||
data_dir = ${PROJECT_DIR}/examples/${PIOENV}/data
|
||||
|
||||
[common]
|
||||
monitor_speed = 115200
|
||||
lib_deps =
|
||||
esp32async/ESPAsyncWebServer@^3.8.0
|
||||
bblanchon/ArduinoJson@^7.4.2
|
||||
|
||||
[env:base]
|
||||
platform = platformio/espressif8266@^4.2.1
|
||||
board = esp01_1m
|
||||
framework = arduino
|
||||
upload_speed = 115200
|
||||
flash_mode = dout
|
||||
monitor_speed = 115200
|
||||
board_build.f_cpu = 80000000L
|
||||
board_build.flash_mode = qio
|
||||
board_build.filesystem = littlefs
|
||||
; note: somehow partition table is not working, so we need to use the ldscript
|
||||
board_build.ldscript = eagle.flash.1m64.ld
|
||||
lib_deps = ${common.lib_deps}
|
||||
build_src_filter =
|
||||
+<examples/base/*.cpp>
|
||||
+<src/spore/*.cpp>
|
||||
+<src/spore/core/*.cpp>
|
||||
+<src/spore/services/*.cpp>
|
||||
+<src/spore/types/*.cpp>
|
||||
+<src/spore/util/*.cpp>
|
||||
+<src/internal/*.cpp>
|
||||
|
||||
[env:d1_mini]
|
||||
platform = platformio/espressif8266@^4.2.1
|
||||
board = d1_mini
|
||||
framework = arduino
|
||||
upload_speed = 115200
|
||||
monitor_speed = 115200
|
||||
board_build.filesystem = littlefs
|
||||
board_build.flash_mode = dio ; D1 Mini uses DIO on 4 Mbit flash
|
||||
board_build.flash_size = 4M
|
||||
board_build.ldscript = eagle.flash.4m1m.ld
|
||||
lib_deps = ${common.lib_deps}
|
||||
build_src_filter =
|
||||
+<examples/base/*.cpp>
|
||||
+<src/spore/*.cpp>
|
||||
+<src/spore/core/*.cpp>
|
||||
+<src/spore/services/*.cpp>
|
||||
+<src/spore/types/*.cpp>
|
||||
+<src/spore/util/*.cpp>
|
||||
+<src/internal/*.cpp>
|
||||
```
|
||||
|
||||
### Key Configuration Details
|
||||
|
||||
- **Framework**: Arduino
|
||||
- **Board**: ESP-01 with 1MB flash
|
||||
- **Upload Speed**: 115200 baud
|
||||
- **Flash Mode**: DOUT (required for ESP-01S)
|
||||
- **Build Type**: Release (optimized for production)
|
||||
|
||||
### Dependencies
|
||||
|
||||
The project requires the following libraries:
|
||||
The project requires the following libraries (resolved via PlatformIO):
|
||||
|
||||
```ini
|
||||
lib_deps =
|
||||
esp32async/ESPAsyncWebServer@^3.8.0
|
||||
bblanchon/ArduinoJson@^7.4.2
|
||||
arkhipenko/TaskScheduler@^3.8.5
|
||||
ESP8266HTTPClient@1.2
|
||||
ESP8266WiFi@1.0
|
||||
```
|
||||
|
||||
### Filesystem, Linker Scripts, and Flash Layout
|
||||
@@ -103,7 +145,6 @@ Notes:
|
||||
- If you need a different FS size, select an appropriate ldscript variant and keep `board_build.filesystem = littlefs`.
|
||||
- On ESP8266, custom partition CSVs are not used for layout; the linker script defines the flash map. This project removed prior `board_build.partitions` usage in favor of explicit `board_build.ldscript` entries per environment.
|
||||
|
||||
|
||||
## Building
|
||||
|
||||
### Basic Build Commands
|
||||
@@ -308,7 +349,7 @@ export API_NODE=192.168.1.100
|
||||
Key configuration files:
|
||||
|
||||
- **`platformio.ini`**: Build and upload configuration
|
||||
- **`src/Config.cpp`**: Application configuration
|
||||
- **`src/spore/types/Config.cpp`**: Default runtime configuration
|
||||
- **`.env`**: Environment variables
|
||||
- **`ctl.sh`**: Build and deployment scripts
|
||||
|
||||
|
||||
79
docs/MonitoringService.md
Normal file
79
docs/MonitoringService.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Monitoring Service
|
||||
|
||||
Exposes system resource metrics via HTTP for observability.
|
||||
|
||||
## Overview
|
||||
|
||||
- **Service name**: `MonitoringService`
|
||||
- **Endpoint**: `GET /api/monitoring/resources`
|
||||
- **Metrics**: CPU usage, memory, filesystem, uptime
|
||||
|
||||
## Endpoint
|
||||
|
||||
### GET /api/monitoring/resources
|
||||
|
||||
Returns real-time system resource metrics.
|
||||
|
||||
Response fields:
|
||||
- `cpu.current_usage`: Current CPU usage percent
|
||||
- `cpu.average_usage`: Average CPU usage percent
|
||||
- `cpu.max_usage`: Max observed CPU usage
|
||||
- `cpu.min_usage`: Min observed CPU usage
|
||||
- `cpu.measurement_count`: Number of measurements
|
||||
- `cpu.is_measuring`: Whether measurement is active
|
||||
- `memory.free_heap`: Free heap bytes
|
||||
- `memory.total_heap`: Total heap bytes (approximate)
|
||||
- `memory.min_free_heap`: Minimum free heap (0 on ESP8266)
|
||||
- `memory.max_alloc_heap`: Max allocatable heap (0 on ESP8266)
|
||||
- `memory.heap_fragmentation`: Fragmentation percent (0 on ESP8266)
|
||||
- `filesystem.total_bytes`: LittleFS total bytes
|
||||
- `filesystem.used_bytes`: Used bytes
|
||||
- `filesystem.free_bytes`: Free bytes
|
||||
- `filesystem.usage_percent`: Usage percent
|
||||
- `system.uptime_ms`: Uptime in milliseconds
|
||||
- `system.uptime_seconds`: Uptime in seconds
|
||||
- `system.uptime_formatted`: Human-readable uptime
|
||||
|
||||
Example:
|
||||
```json
|
||||
{
|
||||
"cpu": {
|
||||
"current_usage": 3.5,
|
||||
"average_usage": 2.1,
|
||||
"max_usage": 15.2,
|
||||
"min_usage": 0.0,
|
||||
"measurement_count": 120,
|
||||
"is_measuring": true
|
||||
},
|
||||
"memory": {
|
||||
"free_heap": 48748,
|
||||
"total_heap": 81920,
|
||||
"min_free_heap": 0,
|
||||
"max_alloc_heap": 0,
|
||||
"heap_fragmentation": 0,
|
||||
"heap_usage_percent": 40.4
|
||||
},
|
||||
"filesystem": {
|
||||
"total_bytes": 65536,
|
||||
"used_bytes": 10240,
|
||||
"free_bytes": 55296,
|
||||
"usage_percent": 15.6
|
||||
},
|
||||
"system": {
|
||||
"uptime_ms": 123456,
|
||||
"uptime_seconds": 123,
|
||||
"uptime_formatted": "0h 2m 3s"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `MonitoringService` reads from `CpuUsage` and ESP8266 SDK APIs.
|
||||
- Filesystem metrics are gathered from LittleFS.
|
||||
- CPU measurement is bracketed by `Spore::loop()` calling `cpuUsage.startMeasurement()` and `cpuUsage.endMeasurement()`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If `filesystem.total_bytes` is zero, ensure LittleFS is enabled in `platformio.ini` and an FS image is uploaded.
|
||||
- CPU usage values remain zero until the main loop runs and CPU measurement is started.
|
||||
@@ -15,15 +15,8 @@ Complete API reference with detailed endpoint documentation, examples, and integ
|
||||
- Task management workflows
|
||||
- Cluster monitoring examples
|
||||
|
||||
### 📖 [TaskManager.md](./TaskManager.md)
|
||||
Comprehensive guide to the TaskManager system for background task management.
|
||||
|
||||
**Includes:**
|
||||
- Basic usage examples
|
||||
- Advanced binding techniques
|
||||
- Task status monitoring
|
||||
- API integration details
|
||||
- Performance considerations
|
||||
### 📖 [MonitoringService.md](./MonitoringService.md)
|
||||
System resource monitoring API for CPU, memory, filesystem, and uptime.
|
||||
|
||||
### 📖 [TaskManagement.md](./TaskManagement.md)
|
||||
Complete guide to the task management system with examples and best practices.
|
||||
|
||||
98
docs/StreamingAPI.md
Normal file
98
docs/StreamingAPI.md
Normal file
@@ -0,0 +1,98 @@
|
||||
## Streaming API (WebSocket)
|
||||
|
||||
### Overview
|
||||
|
||||
The streaming API exposes an event-driven WebSocket at `/ws`. It bridges between external clients and the internal event bus:
|
||||
|
||||
- Incoming WebSocket JSON `{ event, payload }` → `ctx.fire(event, payload)`
|
||||
- Local events → broadcasted to all connected WebSocket clients as `{ event, payload }`
|
||||
|
||||
This allows real-time control and observation of the system without polling.
|
||||
|
||||
### URL
|
||||
|
||||
- `ws://<device-ip>/ws`
|
||||
|
||||
### Message Format
|
||||
|
||||
- Client → Device
|
||||
```json
|
||||
{
|
||||
"event": "<event-name>",
|
||||
"payload": "<json-string>" | { /* inline JSON */ }
|
||||
}
|
||||
```
|
||||
|
||||
- Device → Client
|
||||
```json
|
||||
{
|
||||
"event": "<event-name>",
|
||||
"payload": "<json-string>"
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The device accepts `payload` as a string or a JSON object/array. Objects are serialized into a string before dispatching to local subscribers to keep a consistent downstream contract.
|
||||
- A minimal ack `{ "ok": true }` is sent after a valid inbound message.
|
||||
|
||||
#### Echo suppression (origin tagging)
|
||||
|
||||
- To prevent the sender from receiving an immediate echo of its own message, the server injects a private field into JSON payloads:
|
||||
- `_origin: "ws:<clientId>"`
|
||||
- When re-broadcasting local events to WebSocket clients, the server:
|
||||
- Strips the `_origin` field from the outgoing payload
|
||||
- Skips the originating `clientId` so only other clients receive the message
|
||||
- If a payload is not valid JSON (plain string), no origin tag is injected and the message may be echoed
|
||||
|
||||
### Event Bus Integration
|
||||
|
||||
- The WebSocket registers an `onAny` subscriber to `NodeContext` so that all local events are mirrored to clients.
|
||||
- Services should subscribe to specific events via `ctx.on("<name>", ...)`.
|
||||
|
||||
### Examples
|
||||
|
||||
1) Set a solid color on NeoPattern:
|
||||
```json
|
||||
{
|
||||
"event": "api/neopattern/color",
|
||||
"payload": { "color": "#FF0000", "brightness": 128 }
|
||||
}
|
||||
```
|
||||
|
||||
2) Broadcast a cluster event (delegated to core):
|
||||
```json
|
||||
{
|
||||
"event": "cluster/broadcast",
|
||||
"payload": {
|
||||
"event": "api/neopattern/color",
|
||||
"data": { "color": "#00FF00", "brightness": 128 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reference Implementation
|
||||
|
||||
- WebSocket setup and bridging are implemented in `ApiServer`.
|
||||
- Global event subscription uses `NodeContext::onAny`.
|
||||
|
||||
Related docs:
|
||||
- [`ClusterBroadcast.md`](./ClusterBroadcast.md) — centralized UDP broadcasting and CLUSTER_EVENT format
|
||||
|
||||
### Things to consider
|
||||
|
||||
- High-frequency updates can overwhelm ESP8266:
|
||||
- Frequent JSON parse/serialize and `String` allocations fragment heap and may cause resets (e.g., Exception(3)).
|
||||
- UDP broadcast on every message amplifies load; WiFi/UDP buffers can back up.
|
||||
- Prefer ≥50–100 ms intervals; microbursts at 10 ms are risky.
|
||||
- Throttle and coalesce:
|
||||
- Add a minimum interval in the core `cluster/broadcast` handler.
|
||||
- Optionally drop redundant updates (e.g., same color as previous).
|
||||
- Reduce allocations:
|
||||
- Reuse `StaticJsonDocument`/preallocated buffers in hot paths.
|
||||
- Avoid re-serializing when possible; pass-through payload strings.
|
||||
- Reserve `String` capacity when reuse is needed.
|
||||
- Yielding:
|
||||
- Call `yield()` in long-running or bursty paths to avoid WDT.
|
||||
- Packet size:
|
||||
- Keep payloads small to fit `ClusterProtocol::UDP_BUF_SIZE` and reduce airtime.
|
||||
|
||||
@@ -11,6 +11,31 @@ The TaskManager system provides:
|
||||
- **Status Monitoring**: View task status and configuration
|
||||
- **Automatic Lifecycle**: Tasks are automatically managed and executed
|
||||
|
||||
## Service Interface Integration
|
||||
|
||||
Services now implement a unified interface for both endpoint and task registration:
|
||||
|
||||
```cpp
|
||||
class MyService : public Service {
|
||||
public:
|
||||
void registerEndpoints(ApiServer& api) override {
|
||||
// Register HTTP endpoints
|
||||
api.registerEndpoint("/api/my/status", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleStatus(request); });
|
||||
}
|
||||
|
||||
void registerTasks(TaskManager& taskManager) override {
|
||||
// Register background tasks
|
||||
taskManager.registerTask("my_heartbeat", 2000,
|
||||
[this]() { sendHeartbeat(); });
|
||||
taskManager.registerTask("my_maintenance", 30000,
|
||||
[this]() { performMaintenance(); });
|
||||
}
|
||||
|
||||
const char* getName() const override { return "MyService"; }
|
||||
};
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```cpp
|
||||
@@ -27,6 +52,46 @@ taskManager.registerTask("maintenance", 30000, maintenanceFunction);
|
||||
taskManager.initialize();
|
||||
```
|
||||
|
||||
## Service Lifecycle
|
||||
|
||||
The Spore framework automatically manages service registration and task lifecycle:
|
||||
|
||||
### Service Registration Process
|
||||
|
||||
1. **Service Creation**: Services are created with required dependencies (NodeContext, TaskManager, etc.)
|
||||
2. **Service Registration**: Services are registered with the Spore framework via `spore.registerService()`
|
||||
3. **Endpoint Registration**: When `spore.begin()` is called, `registerEndpoints()` is called for each service
|
||||
4. **Task Registration**: Simultaneously, `registerTasks()` is called for each service
|
||||
5. **Task Initialization**: The TaskManager initializes all registered tasks
|
||||
6. **Execution**: Tasks run in the main loop when their intervals elapse
|
||||
|
||||
### Framework Integration
|
||||
|
||||
```cpp
|
||||
void setup() {
|
||||
spore.setup();
|
||||
|
||||
// Create service with dependencies
|
||||
MyService* service = new MyService(spore.getContext(), spore.getTaskManager());
|
||||
|
||||
// Register service (endpoints and tasks will be registered when begin() is called)
|
||||
spore.registerService(service);
|
||||
|
||||
// This triggers registerEndpoints() and registerTasks() for all services
|
||||
spore.begin();
|
||||
}
|
||||
```
|
||||
|
||||
### Dynamic Service Addition
|
||||
|
||||
Services can be added after the framework has started:
|
||||
|
||||
```cpp
|
||||
// Add service to running framework
|
||||
MyService* newService = new MyService(spore.getContext(), spore.getTaskManager());
|
||||
spore.registerService(newService); // Immediately registers endpoints and tasks
|
||||
```
|
||||
|
||||
## Task Registration Methods
|
||||
|
||||
### Using std::bind with Member Functions (Recommended)
|
||||
@@ -153,7 +218,62 @@ taskManager.registerTask("lambda_task", 2000,
|
||||
|
||||
## Adding Custom Tasks
|
||||
|
||||
### Method 1: Using std::bind (Recommended)
|
||||
### Method 1: Service Interface (Recommended)
|
||||
|
||||
1. **Create your service class implementing the Service interface**:
|
||||
```cpp
|
||||
class SensorService : public Service {
|
||||
public:
|
||||
SensorService(NodeContext& ctx, TaskManager& taskManager)
|
||||
: ctx(ctx), taskManager(taskManager) {}
|
||||
|
||||
void registerEndpoints(ApiServer& api) override {
|
||||
api.registerEndpoint("/api/sensor/status", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleStatus(request); });
|
||||
}
|
||||
|
||||
void registerTasks(TaskManager& taskManager) override {
|
||||
taskManager.registerTask("temp_read", 1000,
|
||||
[this]() { readTemperature(); });
|
||||
taskManager.registerTask("calibrate", 60000,
|
||||
[this]() { calibrateSensors(); });
|
||||
}
|
||||
|
||||
const char* getName() const override { return "SensorService"; }
|
||||
|
||||
private:
|
||||
NodeContext& ctx;
|
||||
TaskManager& taskManager;
|
||||
|
||||
void readTemperature() {
|
||||
// Read sensor logic
|
||||
Serial.println("Reading temperature");
|
||||
}
|
||||
|
||||
void calibrateSensors() {
|
||||
// Calibration logic
|
||||
Serial.println("Calibrating sensors");
|
||||
}
|
||||
|
||||
void handleStatus(AsyncWebServerRequest* request) {
|
||||
// Handle status request
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
2. **Register with Spore framework**:
|
||||
```cpp
|
||||
void setup() {
|
||||
spore.setup();
|
||||
|
||||
SensorService* sensorService = new SensorService(spore.getContext(), spore.getTaskManager());
|
||||
spore.registerService(sensorService);
|
||||
|
||||
spore.begin(); // This will call registerTasks() automatically
|
||||
}
|
||||
```
|
||||
|
||||
### Method 2: Direct TaskManager Registration
|
||||
|
||||
1. **Create your service class**:
|
||||
```cpp
|
||||
@@ -181,7 +301,7 @@ taskManager.registerTask("lambda_task", 2000,
|
||||
std::bind(&SensorService::calibrateSensors, &sensors));
|
||||
```
|
||||
|
||||
### Method 2: Traditional Functions
|
||||
### Method 3: Traditional Functions
|
||||
|
||||
1. **Define your task function**:
|
||||
```cpp
|
||||
@@ -308,38 +428,80 @@ curl -X POST http://192.168.1.100/api/tasks/control \
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use std::bind for member functions**: Cleaner than wrapper functions
|
||||
2. **Group related tasks**: Register multiple related operations in a single task
|
||||
1. **Use Service Interface**: Implement the Service interface for clean integration with the framework
|
||||
2. **Group related tasks**: Register multiple related operations in a single service
|
||||
3. **Monitor task health**: Use the status API to monitor task performance
|
||||
4. **Plan intervals carefully**: Balance responsiveness with system resources
|
||||
5. **Use descriptive names**: Make task names clear and meaningful
|
||||
6. **Separate concerns**: Use registerEndpoints() for HTTP API and registerTasks() for background work
|
||||
7. **Dependency injection**: Pass required dependencies (NodeContext, TaskManager) to service constructors
|
||||
|
||||
## Migration from Wrapper Functions
|
||||
## Migration to Service Interface
|
||||
|
||||
### Before (manual task registration in constructor):
|
||||
```cpp
|
||||
class MyService : public Service {
|
||||
public:
|
||||
MyService(TaskManager& taskManager) : taskManager(taskManager) {
|
||||
// Tasks registered in constructor
|
||||
taskManager.registerTask("heartbeat", 2000, [this]() { sendHeartbeat(); });
|
||||
}
|
||||
|
||||
void registerEndpoints(ApiServer& api) override {
|
||||
// Only endpoints registered here
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### After (using Service interface):
|
||||
```cpp
|
||||
class MyService : public Service {
|
||||
public:
|
||||
MyService(TaskManager& taskManager) : taskManager(taskManager) {
|
||||
// No task registration in constructor
|
||||
}
|
||||
|
||||
void registerEndpoints(ApiServer& api) override {
|
||||
// Register HTTP endpoints
|
||||
api.registerEndpoint("/api/my/status", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleStatus(request); });
|
||||
}
|
||||
|
||||
void registerTasks(TaskManager& taskManager) override {
|
||||
// Register background tasks
|
||||
taskManager.registerTask("heartbeat", 2000, [this]() { sendHeartbeat(); });
|
||||
}
|
||||
|
||||
const char* getName() const override { return "MyService"; }
|
||||
};
|
||||
```
|
||||
|
||||
### Migration from Wrapper Functions
|
||||
|
||||
### Before (with wrapper functions):
|
||||
```cpp
|
||||
void discoverySendTask() { cluster.sendDiscovery(); }
|
||||
void discoveryListenTask() { cluster.listenForDiscovery(); }
|
||||
void clusterListenTask() { cluster.listen(); }
|
||||
|
||||
taskManager.registerTask("discovery_send", interval, discoverySendTask);
|
||||
taskManager.registerTask("discovery_listen", interval, discoveryListenTask);
|
||||
taskManager.registerTask("cluster_listen", interval, clusterListenTask);
|
||||
```
|
||||
|
||||
### After (with std::bind):
|
||||
```cpp
|
||||
taskManager.registerTask("discovery_send", interval,
|
||||
std::bind(&ClusterManager::sendDiscovery, &cluster));
|
||||
taskManager.registerTask("discovery_listen", interval,
|
||||
std::bind(&ClusterManager::listenForDiscovery, &cluster));
|
||||
taskManager.registerTask("cluster_listen", interval,
|
||||
std::bind(&ClusterManager::listen, &cluster));
|
||||
```
|
||||
|
||||
## Compatibility
|
||||
|
||||
- The new `std::bind` support is fully backward compatible
|
||||
- Existing code using function pointers will continue to work
|
||||
- You can mix both approaches in the same project
|
||||
- The new Service interface is fully backward compatible
|
||||
- Existing code using direct TaskManager registration will continue to work
|
||||
- You can mix Service interface and direct registration in the same project
|
||||
- All existing TaskManager methods remain unchanged
|
||||
- New status monitoring methods are additive and don't break existing functionality
|
||||
- The Service interface provides a cleaner, more organized approach for framework integration
|
||||
|
||||
## Related Documentation
|
||||
|
||||
|
||||
248
examples/multimatrix/MultiMatrixService.cpp
Normal file
248
examples/multimatrix/MultiMatrixService.cpp
Normal file
@@ -0,0 +1,248 @@
|
||||
#include "MultiMatrixService.h"
|
||||
#include "spore/core/ApiServer.h"
|
||||
#include "spore/util/Logging.h"
|
||||
#include <ArduinoJson.h>
|
||||
#include <SoftwareSerial.h>
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t DEFAULT_VOLUME = 15;
|
||||
constexpr char API_STATUS_ENDPOINT[] = "/api/audio/status";
|
||||
constexpr char API_CONTROL_ENDPOINT[] = "/api/audio";
|
||||
constexpr char EVENT_TOPIC[] = "audio/player";
|
||||
}
|
||||
|
||||
MultiMatrixService::MultiMatrixService(NodeContext& ctx, TaskManager& taskManager, uint8_t rxPin, uint8_t txPin, uint8_t potentiometerPin)
|
||||
: m_ctx(ctx),
|
||||
m_taskManager(taskManager),
|
||||
m_serial(std::make_unique<SoftwareSerial>(rxPin, txPin)),
|
||||
m_potentiometerPin(potentiometerPin),
|
||||
m_volume(DEFAULT_VOLUME),
|
||||
m_playerReady(false),
|
||||
m_loopEnabled(false) {
|
||||
pinMode(m_potentiometerPin, INPUT);
|
||||
m_serial->begin(9600);
|
||||
|
||||
// DFPlayer Mini requires time to initialize after power-on
|
||||
delay(1000);
|
||||
|
||||
if (m_player.begin(*m_serial)) {
|
||||
m_playerReady = true;
|
||||
m_player.setTimeOut(500);
|
||||
m_player.EQ(DFPLAYER_EQ_NORMAL);
|
||||
m_player.volume(m_volume);
|
||||
LOG_INFO("MultiMatrixService", "DFPlayer initialized successfully");
|
||||
publishEvent("ready");
|
||||
} else {
|
||||
LOG_ERROR("MultiMatrixService", "Failed to initialize DFPlayer");
|
||||
}
|
||||
}
|
||||
|
||||
void MultiMatrixService::registerEndpoints(ApiServer& api) {
|
||||
api.registerEndpoint(API_STATUS_ENDPOINT, HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
api.registerEndpoint(API_CONTROL_ENDPOINT, HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleControlRequest(request); },
|
||||
std::vector<ParamSpec>{
|
||||
ParamSpec{String("action"), true, String("body"), String("string"),
|
||||
{String("play"), String("stop"), String("pause"), String("resume"), String("next"), String("previous"), String("volume"), String("loop")}},
|
||||
ParamSpec{String("volume"), false, String("body"), String("numberRange"), {}, String("15")},
|
||||
ParamSpec{String("loop"), false, String("body"), String("boolean"), {}}
|
||||
});
|
||||
}
|
||||
|
||||
bool MultiMatrixService::isReady() const {
|
||||
return m_playerReady;
|
||||
}
|
||||
|
||||
uint8_t MultiMatrixService::getVolume() const {
|
||||
return m_volume;
|
||||
}
|
||||
|
||||
bool MultiMatrixService::isLoopEnabled() const {
|
||||
return m_loopEnabled;
|
||||
}
|
||||
|
||||
void MultiMatrixService::play() {
|
||||
if (!m_playerReady) {
|
||||
return;
|
||||
}
|
||||
m_player.play();
|
||||
publishEvent("play");
|
||||
LOG_INFO("MultiMatrixService", "Playback started");
|
||||
}
|
||||
|
||||
void MultiMatrixService::stop() {
|
||||
if (!m_playerReady) {
|
||||
return;
|
||||
}
|
||||
m_player.stop();
|
||||
publishEvent("stop");
|
||||
LOG_INFO("MultiMatrixService", "Playback stopped");
|
||||
}
|
||||
|
||||
void MultiMatrixService::pause() {
|
||||
if (!m_playerReady) {
|
||||
return;
|
||||
}
|
||||
m_player.pause();
|
||||
publishEvent("pause");
|
||||
LOG_INFO("MultiMatrixService", "Playback paused");
|
||||
}
|
||||
|
||||
void MultiMatrixService::resume() {
|
||||
if (!m_playerReady) {
|
||||
return;
|
||||
}
|
||||
m_player.start();
|
||||
publishEvent("resume");
|
||||
LOG_INFO("MultiMatrixService", "Playback resumed");
|
||||
}
|
||||
|
||||
void MultiMatrixService::next() {
|
||||
if (!m_playerReady) {
|
||||
return;
|
||||
}
|
||||
m_player.next();
|
||||
publishEvent("next");
|
||||
LOG_INFO("MultiMatrixService", "Next track");
|
||||
}
|
||||
|
||||
void MultiMatrixService::previous() {
|
||||
if (!m_playerReady) {
|
||||
return;
|
||||
}
|
||||
m_player.previous();
|
||||
publishEvent("previous");
|
||||
LOG_INFO("MultiMatrixService", "Previous track");
|
||||
}
|
||||
|
||||
void MultiMatrixService::setVolume(uint8_t volume) {
|
||||
if (!m_playerReady) {
|
||||
return;
|
||||
}
|
||||
const uint8_t clampedVolume = std::min<uint8_t>(volume, MAX_VOLUME);
|
||||
if (clampedVolume == m_volume) {
|
||||
return;
|
||||
}
|
||||
applyVolume(clampedVolume);
|
||||
}
|
||||
|
||||
void MultiMatrixService::setLoop(bool enabled) {
|
||||
if (!m_playerReady) {
|
||||
return;
|
||||
}
|
||||
m_loopEnabled = enabled;
|
||||
if (enabled) {
|
||||
m_player.enableLoop();
|
||||
} else {
|
||||
m_player.disableLoop();
|
||||
}
|
||||
publishEvent("loop");
|
||||
LOG_INFO("MultiMatrixService", String("Loop ") + (enabled ? "enabled" : "disabled"));
|
||||
}
|
||||
|
||||
void MultiMatrixService::registerTasks(TaskManager& taskManager) {
|
||||
taskManager.registerTask("multimatrix_potentiometer", POTENTIOMETER_SAMPLE_INTERVAL_MS,
|
||||
[this]() { pollPotentiometer(); });
|
||||
}
|
||||
|
||||
void MultiMatrixService::pollPotentiometer() {
|
||||
if (!m_playerReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uint16_t rawValue = analogRead(static_cast<uint8_t>(m_potentiometerPin));
|
||||
const uint8_t targetVolume = calculateVolumeFromPotentiometer(rawValue);
|
||||
|
||||
if (targetVolume > m_volume + POT_VOLUME_EPSILON || targetVolume + POT_VOLUME_EPSILON < m_volume) {
|
||||
applyVolume(targetVolume);
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t MultiMatrixService::calculateVolumeFromPotentiometer(uint16_t rawValue) const {
|
||||
// Clamp raw value to prevent underflow (analogRead can return 1024)
|
||||
const uint16_t clampedValue = std::min<uint16_t>(rawValue, 1023U);
|
||||
// Invert: all down (0) = max volume, all up (1023) = min volume
|
||||
const uint16_t invertedValue = 1023U - clampedValue;
|
||||
const uint8_t scaledVolume = static_cast<uint8_t>((static_cast<uint32_t>(invertedValue) * MAX_VOLUME) / 1023U);
|
||||
return std::min<uint8_t>(scaledVolume, MAX_VOLUME);
|
||||
}
|
||||
|
||||
void MultiMatrixService::applyVolume(uint8_t targetVolume) {
|
||||
m_volume = targetVolume;
|
||||
m_player.volume(m_volume);
|
||||
publishEvent("volume");
|
||||
LOG_INFO("MultiMatrixService", String("Volume set to ") + String(m_volume));
|
||||
}
|
||||
|
||||
void MultiMatrixService::handleStatusRequest(AsyncWebServerRequest* request) {
|
||||
StaticJsonDocument<192> doc;
|
||||
doc["ready"] = m_playerReady;
|
||||
doc["volume"] = static_cast<int>(m_volume);
|
||||
doc["loop"] = m_loopEnabled;
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
request->send(200, "application/json", json);
|
||||
}
|
||||
|
||||
void MultiMatrixService::handleControlRequest(AsyncWebServerRequest* request) {
|
||||
String action = request->hasParam("action", true) ? request->getParam("action", true)->value() : "";
|
||||
bool ok = true;
|
||||
|
||||
if (action.equalsIgnoreCase("play")) {
|
||||
play();
|
||||
} else if (action.equalsIgnoreCase("stop")) {
|
||||
stop();
|
||||
} else if (action.equalsIgnoreCase("pause")) {
|
||||
pause();
|
||||
} else if (action.equalsIgnoreCase("resume")) {
|
||||
resume();
|
||||
} else if (action.equalsIgnoreCase("next")) {
|
||||
next();
|
||||
} else if (action.equalsIgnoreCase("previous")) {
|
||||
previous();
|
||||
} else if (action.equalsIgnoreCase("volume")) {
|
||||
if (request->hasParam("volume", true)) {
|
||||
int volumeValue = request->getParam("volume", true)->value().toInt();
|
||||
setVolume(static_cast<uint8_t>(std::max(0, std::min(static_cast<int>(MAX_VOLUME), volumeValue))));
|
||||
} else {
|
||||
ok = false;
|
||||
}
|
||||
} else if (action.equalsIgnoreCase("loop")) {
|
||||
if (request->hasParam("loop", true)) {
|
||||
String loopValue = request->getParam("loop", true)->value();
|
||||
bool enabled = loopValue.equalsIgnoreCase("true") || loopValue == "1";
|
||||
setLoop(enabled);
|
||||
} else {
|
||||
ok = false;
|
||||
}
|
||||
} else {
|
||||
ok = false;
|
||||
}
|
||||
|
||||
StaticJsonDocument<256> resp;
|
||||
resp["success"] = ok;
|
||||
resp["ready"] = m_playerReady;
|
||||
resp["volume"] = static_cast<int>(m_volume);
|
||||
resp["loop"] = m_loopEnabled;
|
||||
if (!ok) {
|
||||
resp["message"] = "Invalid action";
|
||||
}
|
||||
|
||||
String json;
|
||||
serializeJson(resp, json);
|
||||
request->send(ok ? 200 : 400, "application/json", json);
|
||||
}
|
||||
|
||||
void MultiMatrixService::publishEvent(const char* action) {
|
||||
StaticJsonDocument<192> doc;
|
||||
doc["action"] = action;
|
||||
doc["volume"] = static_cast<int>(m_volume);
|
||||
doc["loop"] = m_loopEnabled;
|
||||
String payload;
|
||||
serializeJson(doc, payload);
|
||||
m_ctx.fire(EVENT_TOPIC, &payload);
|
||||
}
|
||||
53
examples/multimatrix/MultiMatrixService.h
Normal file
53
examples/multimatrix/MultiMatrixService.h
Normal file
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
#include <algorithm>
|
||||
#include <memory>
|
||||
#include <SoftwareSerial.h>
|
||||
#include <DFRobotDFPlayerMini.h>
|
||||
#include "spore/Service.h"
|
||||
#include "spore/core/TaskManager.h"
|
||||
#include "spore/core/NodeContext.h"
|
||||
|
||||
class ApiServer;
|
||||
class AsyncWebServerRequest;
|
||||
class MultiMatrixService : public Service {
|
||||
public:
|
||||
MultiMatrixService(NodeContext& ctx, TaskManager& taskManager, uint8_t rxPin, uint8_t txPin, uint8_t potentiometerPin);
|
||||
void registerEndpoints(ApiServer& api) override;
|
||||
void registerTasks(TaskManager& taskManager) override;
|
||||
const char* getName() const override { return "MultiMatrixAudio"; }
|
||||
|
||||
bool isReady() const;
|
||||
uint8_t getVolume() const;
|
||||
bool isLoopEnabled() const;
|
||||
|
||||
void play();
|
||||
void stop();
|
||||
void pause();
|
||||
void resume();
|
||||
void next();
|
||||
void previous();
|
||||
void setVolume(uint8_t volume);
|
||||
void setLoop(bool enabled);
|
||||
|
||||
private:
|
||||
static constexpr uint16_t POTENTIOMETER_SAMPLE_INTERVAL_MS = 200;
|
||||
static constexpr uint8_t MAX_VOLUME = 30;
|
||||
static constexpr uint8_t POT_VOLUME_EPSILON = 2;
|
||||
|
||||
void pollPotentiometer();
|
||||
void handleStatusRequest(AsyncWebServerRequest* request);
|
||||
void handleControlRequest(AsyncWebServerRequest* request);
|
||||
uint8_t calculateVolumeFromPotentiometer(uint16_t rawValue) const;
|
||||
void applyVolume(uint8_t targetVolume);
|
||||
void publishEvent(const char* action);
|
||||
|
||||
NodeContext& m_ctx;
|
||||
TaskManager& m_taskManager;
|
||||
std::unique_ptr<SoftwareSerial> m_serial;
|
||||
DFRobotDFPlayerMini m_player;
|
||||
uint8_t m_potentiometerPin;
|
||||
uint8_t m_volume;
|
||||
bool m_playerReady;
|
||||
bool m_loopEnabled;
|
||||
};
|
||||
19
examples/multimatrix/README.md
Normal file
19
examples/multimatrix/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Multi-Matrix
|
||||
|
||||
This example combines different capabilities:
|
||||
- Spore base stack
|
||||
- use PixelStreamController in Matrix mode 16x16
|
||||
- DFRobotDFPlayerMini audio playback
|
||||
- analog potentiometer to controll audio volume
|
||||
- API and web interface to control the audio player (start, stop, pause, next / previous track, volume)
|
||||
|
||||
## MCU
|
||||
- Wemos D1 Mini
|
||||
|
||||
## Pin Configuration
|
||||
```
|
||||
#define MP3PLAYER_PIN_RX D3
|
||||
#define MP3PLAYER_PIN_TX D4
|
||||
#define MATRIX_PIN D2
|
||||
#define POTI_PIN A0
|
||||
```
|
||||
508
examples/multimatrix/data/public/index.html
Normal file
508
examples/multimatrix/data/public/index.html
Normal file
@@ -0,0 +1,508 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Multi-Matrix Audio Player</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.player-container {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
padding: 3rem 2.5rem;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.player-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.player-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 1rem;
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
border-radius: 20px;
|
||||
font-size: 0.875rem;
|
||||
color: #667eea;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #10b981;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-dot.inactive {
|
||||
background: #ef4444;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.main-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
margin: 2.5rem 0;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
|
||||
}
|
||||
|
||||
.control-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
font-size: 1.5rem;
|
||||
box-shadow: 0 6px 25px rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
|
||||
.secondary-controls {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 2rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
padding: 0.6rem 1.25rem;
|
||||
border: 2px solid #667eea;
|
||||
background: white;
|
||||
color: #667eea;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.secondary-btn:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.secondary-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.volume-section {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.volume-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.volume-value {
|
||||
color: #667eea;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(to right, #667eea 0%, #764ba2 100%);
|
||||
outline: none;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.volume-slider:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 10px rgba(102, 126, 234, 0.5);
|
||||
border: 2px solid #667eea;
|
||||
}
|
||||
|
||||
.volume-slider::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 10px rgba(102, 126, 234, 0.5);
|
||||
border: 2px solid #667eea;
|
||||
}
|
||||
|
||||
.loop-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
border-radius: 12px;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.loop-label {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 52px;
|
||||
height: 28px;
|
||||
background: #cbd5e1;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.toggle-switch.active {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.toggle-switch.active .toggle-slider {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.player-container {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.player-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="player-container">
|
||||
<div class="player-header">
|
||||
<h1 class="player-title">Multi-Matrix Audio</h1>
|
||||
<div class="status-badge">
|
||||
<span class="status-dot" id="statusDot"></span>
|
||||
<span id="statusText">Connecting...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-controls">
|
||||
<button class="control-btn" onclick="sendAction('previous')" title="Previous">
|
||||
<span class="icon">⏮</span>
|
||||
</button>
|
||||
<button class="control-btn play-btn" id="playBtn" onclick="togglePlayPause()" title="Play/Pause">
|
||||
<span class="icon" id="playIcon">▶</span>
|
||||
</button>
|
||||
<button class="control-btn" onclick="sendAction('next')" title="Next">
|
||||
<span class="icon">⏭</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="secondary-controls">
|
||||
<button class="secondary-btn" onclick="sendAction('stop')">⏹ Stop</button>
|
||||
<button class="secondary-btn" onclick="sendAction('resume')">▶ Resume</button>
|
||||
</div>
|
||||
|
||||
<div class="volume-section">
|
||||
<div class="volume-header">
|
||||
<span>🔊 Volume</span>
|
||||
<span class="volume-value" id="volumeDisplay">15</span>
|
||||
</div>
|
||||
<input type="range"
|
||||
class="volume-slider"
|
||||
id="volumeSlider"
|
||||
min="0"
|
||||
max="30"
|
||||
value="15"
|
||||
oninput="updateVolumeDisplay(this.value)"
|
||||
onchange="setVolume(this.value)">
|
||||
</div>
|
||||
|
||||
<div class="loop-toggle">
|
||||
<div class="loop-label">
|
||||
<span>🔁 Loop Mode</span>
|
||||
</div>
|
||||
<div class="toggle-switch" id="loopToggle" onclick="toggleLoop()">
|
||||
<div class="toggle-slider"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let isPlaying = false;
|
||||
let loopEnabled = false;
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/audio/status');
|
||||
if (!response.ok) {
|
||||
throw new Error('Status request failed');
|
||||
}
|
||||
const status = await response.json();
|
||||
updateUIStatus(status);
|
||||
} catch (error) {
|
||||
document.getElementById('statusText').textContent = 'Error';
|
||||
document.getElementById('statusDot').className = 'status-dot inactive';
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateUIStatus(status) {
|
||||
const statusDot = document.getElementById('statusDot');
|
||||
const statusText = document.getElementById('statusText');
|
||||
|
||||
if (status.ready) {
|
||||
statusDot.className = 'status-dot';
|
||||
statusText.textContent = 'Ready';
|
||||
} else {
|
||||
statusDot.className = 'status-dot inactive';
|
||||
statusText.textContent = 'Not Ready';
|
||||
}
|
||||
|
||||
if ('volume' in status) {
|
||||
document.getElementById('volumeSlider').value = status.volume;
|
||||
document.getElementById('volumeDisplay').textContent = status.volume;
|
||||
}
|
||||
|
||||
if ('loop' in status) {
|
||||
loopEnabled = status.loop;
|
||||
const loopToggle = document.getElementById('loopToggle');
|
||||
if (loopEnabled) {
|
||||
loopToggle.classList.add('active');
|
||||
} else {
|
||||
loopToggle.classList.remove('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendAction(action, params = {}) {
|
||||
try {
|
||||
const formData = new URLSearchParams({ action, ...params });
|
||||
const response = await fetch('/api/audio', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: formData
|
||||
});
|
||||
const result = await response.json();
|
||||
updateUIStatus(result);
|
||||
|
||||
if (action === 'play' || action === 'resume') {
|
||||
isPlaying = true;
|
||||
updatePlayButton();
|
||||
} else if (action === 'pause' || action === 'stop') {
|
||||
isPlaying = false;
|
||||
updatePlayButton();
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
alert(result.message || 'Action failed');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Request failed: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function togglePlayPause() {
|
||||
if (isPlaying) {
|
||||
sendAction('pause');
|
||||
} else {
|
||||
sendAction('play');
|
||||
}
|
||||
}
|
||||
|
||||
function updatePlayButton() {
|
||||
const playIcon = document.getElementById('playIcon');
|
||||
playIcon.textContent = isPlaying ? '⏸' : '▶';
|
||||
}
|
||||
|
||||
function updateVolumeDisplay(value) {
|
||||
document.getElementById('volumeDisplay').textContent = value;
|
||||
}
|
||||
|
||||
function setVolume(value) {
|
||||
sendAction('volume', { volume: value });
|
||||
}
|
||||
|
||||
function toggleLoop() {
|
||||
loopEnabled = !loopEnabled;
|
||||
const loopToggle = document.getElementById('loopToggle');
|
||||
if (loopEnabled) {
|
||||
loopToggle.classList.add('active');
|
||||
} else {
|
||||
loopToggle.classList.remove('active');
|
||||
}
|
||||
sendAction('loop', { loop: loopEnabled });
|
||||
}
|
||||
|
||||
fetchStatus();
|
||||
setInterval(fetchStatus, 5000);
|
||||
|
||||
function setupWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(protocol + '//' + window.location.host + '/ws');
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
if (payload.event === 'audio/player' && payload.payload) {
|
||||
const data = JSON.parse(payload.payload);
|
||||
|
||||
// Skip debug messages
|
||||
if (data.action === 'pot_debug') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('volume' in data) {
|
||||
document.getElementById('volumeSlider').value = data.volume;
|
||||
document.getElementById('volumeDisplay').textContent = data.volume;
|
||||
}
|
||||
|
||||
if ('loop' in data) {
|
||||
loopEnabled = data.loop;
|
||||
const loopToggle = document.getElementById('loopToggle');
|
||||
if (loopEnabled) {
|
||||
loopToggle.classList.add('active');
|
||||
} else {
|
||||
loopToggle.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
if (data.action) {
|
||||
const statusText = document.getElementById('statusText');
|
||||
const statusDot = document.getElementById('statusDot');
|
||||
|
||||
if (data.action === 'play' || data.action === 'resume' || data.action === 'next' || data.action === 'previous') {
|
||||
statusText.textContent = 'Playing';
|
||||
statusDot.className = 'status-dot';
|
||||
isPlaying = true;
|
||||
updatePlayButton();
|
||||
} else if (data.action === 'pause') {
|
||||
statusText.textContent = 'Paused';
|
||||
statusDot.className = 'status-dot';
|
||||
isPlaying = false;
|
||||
updatePlayButton();
|
||||
} else if (data.action === 'stop') {
|
||||
statusText.textContent = 'Stopped';
|
||||
statusDot.className = 'status-dot inactive';
|
||||
isPlaying = false;
|
||||
updatePlayButton();
|
||||
} else if (data.action === 'ready') {
|
||||
statusText.textContent = 'Ready';
|
||||
statusDot.className = 'status-dot';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('WebSocket parse error', err);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setTimeout(setupWebSocket, 3000);
|
||||
};
|
||||
}
|
||||
|
||||
setupWebSocket();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
54
examples/multimatrix/main.cpp
Normal file
54
examples/multimatrix/main.cpp
Normal file
@@ -0,0 +1,54 @@
|
||||
#include <Arduino.h>
|
||||
#include <memory>
|
||||
#include "spore/Spore.h"
|
||||
#include "spore/util/Logging.h"
|
||||
#include "../pixelstream/PixelStreamController.h"
|
||||
#include "MultiMatrixService.h"
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t MATRIX_PIN = D2;
|
||||
constexpr uint16_t MATRIX_PIXEL_COUNT = 256;
|
||||
constexpr uint8_t MATRIX_BRIGHTNESS = 40;
|
||||
constexpr uint16_t MATRIX_WIDTH = 16;
|
||||
constexpr bool MATRIX_SERPENTINE = false;
|
||||
constexpr neoPixelType MATRIX_PIXEL_TYPE = NEO_GRB + NEO_KHZ800;
|
||||
constexpr uint8_t MP3PLAYER_PIN_RX = D3;
|
||||
constexpr uint8_t MP3PLAYER_PIN_TX = D4;
|
||||
constexpr uint8_t POTENTIOMETER_PIN = A0;
|
||||
}
|
||||
|
||||
Spore spore({
|
||||
{"app", "multimatrix"},
|
||||
{"role", "media"},
|
||||
{"matrix", String(MATRIX_PIXEL_COUNT)}
|
||||
});
|
||||
|
||||
std::unique_ptr<PixelStreamController> pixelController;
|
||||
std::shared_ptr<MultiMatrixService> audioService;
|
||||
|
||||
void setup() {
|
||||
spore.setup();
|
||||
|
||||
PixelStreamConfig config{
|
||||
MATRIX_PIN,
|
||||
MATRIX_PIXEL_COUNT,
|
||||
MATRIX_BRIGHTNESS,
|
||||
MATRIX_WIDTH,
|
||||
MATRIX_SERPENTINE,
|
||||
MATRIX_PIXEL_TYPE
|
||||
};
|
||||
|
||||
pixelController = std::make_unique<PixelStreamController>(spore.getContext(), config);
|
||||
pixelController->begin();
|
||||
|
||||
audioService = std::make_shared<MultiMatrixService>(spore.getContext(), spore.getTaskManager(), MP3PLAYER_PIN_RX, MP3PLAYER_PIN_TX, POTENTIOMETER_PIN);
|
||||
spore.registerService(audioService);
|
||||
|
||||
spore.begin();
|
||||
|
||||
LOG_INFO("MultiMatrix", "Setup complete");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
spore.loop();
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
#include "NeoPatternService.h"
|
||||
#include "spore/core/ApiServer.h"
|
||||
#include "spore/util/Logging.h"
|
||||
#include "spore/internal/Globals.h"
|
||||
#include <ArduinoJson.h>
|
||||
#include <ESP8266WiFi.h>
|
||||
|
||||
NeoPatternService::NeoPatternService(TaskManager& taskMgr, const NeoPixelConfig& config)
|
||||
NeoPatternService::NeoPatternService(NodeContext& ctx, TaskManager& taskMgr, const NeoPixelConfig& config)
|
||||
: taskManager(taskMgr),
|
||||
ctx(ctx),
|
||||
config(config),
|
||||
activePattern(NeoPatternType::RAINBOW_CYCLE),
|
||||
direction(NeoDirection::FORWARD),
|
||||
@@ -31,7 +34,7 @@ NeoPatternService::NeoPatternService(TaskManager& taskMgr, const NeoPixelConfig&
|
||||
neoPattern->Direction = static_cast<::direction>(direction);
|
||||
|
||||
registerPatterns();
|
||||
registerTasks();
|
||||
registerEventHandlers();
|
||||
initialized = true;
|
||||
|
||||
LOG_INFO("NeoPattern", "Service initialized");
|
||||
@@ -45,17 +48,17 @@ NeoPatternService::~NeoPatternService() {
|
||||
|
||||
void NeoPatternService::registerEndpoints(ApiServer& api) {
|
||||
// Status endpoint
|
||||
api.addEndpoint("/api/neopattern/status", HTTP_GET,
|
||||
api.registerEndpoint("/api/neopattern/status", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
// Patterns list endpoint
|
||||
api.addEndpoint("/api/neopattern/patterns", HTTP_GET,
|
||||
api.registerEndpoint("/api/neopattern/patterns", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handlePatternsRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
// Control endpoint
|
||||
api.addEndpoint("/api/neopattern", HTTP_POST,
|
||||
api.registerEndpoint("/api/neopattern", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleControlRequest(request); },
|
||||
std::vector<ParamSpec>{
|
||||
ParamSpec{String("pattern"), false, String("body"), String("string"), patternNamesVector()},
|
||||
@@ -64,11 +67,12 @@ void NeoPatternService::registerEndpoints(ApiServer& api) {
|
||||
ParamSpec{String("brightness"), false, String("body"), String("numberRange"), {}, String("80")},
|
||||
ParamSpec{String("total_steps"), false, String("body"), String("numberRange"), {}, String("16")},
|
||||
ParamSpec{String("direction"), false, String("body"), String("string"), {String("forward"), String("reverse")}},
|
||||
ParamSpec{String("interval"), false, String("body"), String("number"), {}, String("100")}
|
||||
ParamSpec{String("interval"), false, String("body"), String("number"), {}, String("100")},
|
||||
ParamSpec{String("broadcast"), false, String("body"), String("boolean"), {}}
|
||||
});
|
||||
|
||||
// State endpoint for complex state updates
|
||||
api.addEndpoint("/api/neopattern/state", HTTP_POST,
|
||||
api.registerEndpoint("/api/neopattern/state", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleStateRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
}
|
||||
@@ -119,61 +123,49 @@ void NeoPatternService::handlePatternsRequest(AsyncWebServerRequest* request) {
|
||||
|
||||
void NeoPatternService::handleControlRequest(AsyncWebServerRequest* request) {
|
||||
bool updated = false;
|
||||
bool broadcast = false;
|
||||
|
||||
if (request->hasParam("pattern", true)) {
|
||||
String name = request->getParam("pattern", true)->value();
|
||||
if (isValidPattern(name)) {
|
||||
setPatternByName(name);
|
||||
updated = true;
|
||||
} else {
|
||||
// Invalid pattern name - could add error handling here
|
||||
LOG_WARN("NeoPattern", "Invalid pattern name: " + name);
|
||||
}
|
||||
if (request->hasParam("broadcast", true)) {
|
||||
String b = request->getParam("broadcast", true)->value();
|
||||
broadcast = b.equalsIgnoreCase("true") || b == "1";
|
||||
}
|
||||
|
||||
if (request->hasParam("color", true)) {
|
||||
String colorStr = request->getParam("color", true)->value();
|
||||
uint32_t color = parseColor(colorStr);
|
||||
setColor(color);
|
||||
// Build JSON payload from provided params (single source of truth)
|
||||
JsonDocument payload;
|
||||
bool any = false;
|
||||
if (request->hasParam("pattern", true)) { payload["pattern"] = request->getParam("pattern", true)->value(); any = true; }
|
||||
if (request->hasParam("color", true)) { payload["color"] = request->getParam("color", true)->value(); any = true; }
|
||||
if (request->hasParam("color2", true)) { payload["color2"] = request->getParam("color2", true)->value(); any = true; }
|
||||
if (request->hasParam("brightness", true)) { payload["brightness"] = request->getParam("brightness", true)->value(); any = true; }
|
||||
if (request->hasParam("total_steps", true)) { payload["total_steps"] = request->getParam("total_steps", true)->value(); any = true; }
|
||||
if (request->hasParam("direction", true)) { payload["direction"] = request->getParam("direction", true)->value(); any = true; }
|
||||
if (request->hasParam("interval", true)) { payload["interval"] = request->getParam("interval", true)->value(); any = true; }
|
||||
|
||||
String payloadStr;
|
||||
serializeJson(payload, payloadStr);
|
||||
|
||||
// Always apply locally via event so we have a single codepath for updates
|
||||
if (any) {
|
||||
std::string ev = "api/neopattern";
|
||||
String localData = payloadStr;
|
||||
LOG_INFO("NeoPattern", String("Applying local api/neopattern via event payloadLen=") + String(payloadStr.length()));
|
||||
ctx.fire(ev, &localData);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (request->hasParam("color2", true)) {
|
||||
String colorStr = request->getParam("color2", true)->value();
|
||||
uint32_t color = parseColor(colorStr);
|
||||
setColor2(color);
|
||||
updated = true;
|
||||
}
|
||||
// Broadcast to peers if requested (delegate to core broadcast handler)
|
||||
if (broadcast && any) {
|
||||
JsonDocument eventDoc;
|
||||
eventDoc["event"] = "api/neopattern";
|
||||
eventDoc["data"] = payloadStr; // data is JSON string
|
||||
|
||||
if (request->hasParam("brightness", true)) {
|
||||
int b = request->getParam("brightness", true)->value().toInt();
|
||||
if (b < 0) b = 0;
|
||||
if (b > 255) b = 255;
|
||||
setBrightness(static_cast<uint8_t>(b));
|
||||
updated = true;
|
||||
}
|
||||
String eventJson;
|
||||
serializeJson(eventDoc, eventJson);
|
||||
|
||||
if (request->hasParam("total_steps", true)) {
|
||||
int steps = request->getParam("total_steps", true)->value().toInt();
|
||||
if (steps > 0) {
|
||||
setTotalSteps(static_cast<uint16_t>(steps));
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (request->hasParam("direction", true)) {
|
||||
String dirStr = request->getParam("direction", true)->value();
|
||||
NeoDirection dir = (dirStr.equalsIgnoreCase("reverse")) ? NeoDirection::REVERSE : NeoDirection::FORWARD;
|
||||
setDirection(dir);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (request->hasParam("interval", true)) {
|
||||
unsigned long interval = request->getParam("interval", true)->value().toInt();
|
||||
if (interval > 0) {
|
||||
setUpdateInterval(interval);
|
||||
updated = true;
|
||||
}
|
||||
LOG_INFO("NeoPattern", String("Submitting cluster/broadcast for api/neopattern payloadLen=") + String(payloadStr.length()));
|
||||
std::string ev = "cluster/broadcast";
|
||||
String eventStr = eventJson;
|
||||
ctx.fire(ev, &eventStr);
|
||||
}
|
||||
|
||||
// Return current state
|
||||
@@ -192,6 +184,139 @@ void NeoPatternService::handleControlRequest(AsyncWebServerRequest* request) {
|
||||
serializeJson(resp, json);
|
||||
request->send(200, "application/json", json);
|
||||
}
|
||||
void NeoPatternService::registerEventHandlers() {
|
||||
ctx.on("api/neopattern", [this](void* dataPtr) {
|
||||
String* jsonStr = static_cast<String*>(dataPtr);
|
||||
if (!jsonStr) {
|
||||
LOG_WARN("NeoPattern", "Received api/neopattern with null dataPtr");
|
||||
return;
|
||||
}
|
||||
LOG_INFO("NeoPattern", String("Received api/neopattern event dataLen=") + String(jsonStr->length()));
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, *jsonStr);
|
||||
if (err) {
|
||||
LOG_WARN("NeoPattern", String("Failed to parse CLUSTER_EVENT data: ") + err.c_str());
|
||||
return;
|
||||
}
|
||||
JsonObject obj = doc.as<JsonObject>();
|
||||
bool applied = applyControlParams(obj);
|
||||
if (applied) {
|
||||
LOG_INFO("NeoPattern", "Applied control from CLUSTER_EVENT");
|
||||
}
|
||||
});
|
||||
|
||||
// Solid color event: sets all pixels to the same color
|
||||
ctx.on("api/neopattern/color", [this](void* dataPtr) {
|
||||
String* jsonStr = static_cast<String*>(dataPtr);
|
||||
if (!jsonStr) {
|
||||
LOG_WARN("NeoPattern", "Received api/neopattern/color with null dataPtr");
|
||||
return;
|
||||
}
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, *jsonStr);
|
||||
if (err) {
|
||||
LOG_WARN("NeoPattern", String("Failed to parse color event data: ") + err.c_str());
|
||||
return;
|
||||
}
|
||||
JsonObject obj = doc.as<JsonObject>();
|
||||
// color can be string or number
|
||||
String colorStr;
|
||||
if (obj["color"].is<const char*>() || obj["color"].is<String>()) {
|
||||
colorStr = obj["color"].as<String>();
|
||||
} else if (obj["color"].is<long>() || obj["color"].is<int>()) {
|
||||
colorStr = String(obj["color"].as<long>());
|
||||
} else {
|
||||
LOG_WARN("NeoPattern", "api/neopattern/color missing 'color'");
|
||||
return;
|
||||
}
|
||||
|
||||
// Optional brightness
|
||||
if (obj["brightness"].is<int>() || obj["brightness"].is<long>()) {
|
||||
int b = obj["brightness"].as<int>();
|
||||
if (b < 0) b = 0; if (b > 255) b = 255;
|
||||
setBrightness(static_cast<uint8_t>(b));
|
||||
}
|
||||
|
||||
uint32_t color = parseColor(colorStr);
|
||||
setPattern(NeoPatternType::NONE);
|
||||
setColor(color);
|
||||
LOG_INFO("NeoPattern", String("Set solid color ") + colorStr);
|
||||
});
|
||||
}
|
||||
|
||||
bool NeoPatternService::applyControlParams(const JsonObject& obj) {
|
||||
bool updated = false;
|
||||
if (obj["pattern"].is<const char*>() || obj["pattern"].is<String>()) {
|
||||
String name = obj["pattern"].as<String>();
|
||||
if (isValidPattern(name)) {
|
||||
setPatternByName(name);
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
if (obj["color"].is<const char*>() || obj["color"].is<String>() || obj["color"].is<long>() || obj["color"].is<int>()) {
|
||||
String colorStr;
|
||||
if (obj["color"].is<long>() || obj["color"].is<int>()) {
|
||||
colorStr = String(obj["color"].as<long>());
|
||||
} else {
|
||||
colorStr = obj["color"].as<String>();
|
||||
}
|
||||
uint32_t color = parseColor(colorStr);
|
||||
setColor(color);
|
||||
updated = true;
|
||||
}
|
||||
if (obj["color2"].is<const char*>() || obj["color2"].is<String>() || obj["color2"].is<long>() || obj["color2"].is<int>()) {
|
||||
String colorStr;
|
||||
if (obj["color2"].is<long>() || obj["color2"].is<int>()) {
|
||||
colorStr = String(obj["color2"].as<long>());
|
||||
} else {
|
||||
colorStr = obj["color2"].as<String>();
|
||||
}
|
||||
uint32_t color = parseColor(colorStr);
|
||||
setColor2(color);
|
||||
updated = true;
|
||||
}
|
||||
if (obj["brightness"].is<int>() || obj["brightness"].is<long>() || obj["brightness"].is<const char*>() || obj["brightness"].is<String>()) {
|
||||
int b = 0;
|
||||
if (obj["brightness"].is<int>() || obj["brightness"].is<long>()) {
|
||||
b = obj["brightness"].as<int>();
|
||||
} else {
|
||||
b = String(obj["brightness"].as<String>()).toInt();
|
||||
}
|
||||
if (b < 0) {
|
||||
b = 0;
|
||||
}
|
||||
if (b > 255) {
|
||||
b = 255;
|
||||
}
|
||||
setBrightness(static_cast<uint8_t>(b));
|
||||
updated = true;
|
||||
}
|
||||
if (obj["total_steps"].is<int>() || obj["total_steps"].is<long>() || obj["total_steps"].is<const char*>() || obj["total_steps"].is<String>()) {
|
||||
int steps = 0;
|
||||
if (obj["total_steps"].is<int>() || obj["total_steps"].is<long>()) {
|
||||
steps = obj["total_steps"].as<int>();
|
||||
} else {
|
||||
steps = String(obj["total_steps"].as<String>()).toInt();
|
||||
}
|
||||
if (steps > 0) { setTotalSteps(static_cast<uint16_t>(steps)); updated = true; }
|
||||
}
|
||||
if (obj["direction"].is<const char*>() || obj["direction"].is<String>()) {
|
||||
String dirStr = obj["direction"].as<String>();
|
||||
NeoDirection dir = (dirStr.equalsIgnoreCase("reverse")) ? NeoDirection::REVERSE : NeoDirection::FORWARD;
|
||||
setDirection(dir);
|
||||
updated = true;
|
||||
}
|
||||
if (obj["interval"].is<int>() || obj["interval"].is<long>() || obj["interval"].is<const char*>() || obj["interval"].is<String>()) {
|
||||
unsigned long interval = 0;
|
||||
if (obj["interval"].is<int>() || obj["interval"].is<long>()) {
|
||||
interval = obj["interval"].as<unsigned long>();
|
||||
} else {
|
||||
interval = String(obj["interval"].as<String>()).toInt();
|
||||
}
|
||||
if (interval > 0) { setUpdateInterval(interval); updated = true; }
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
void NeoPatternService::handleStateRequest(AsyncWebServerRequest* request) {
|
||||
if (request->contentType() != "application/json") {
|
||||
@@ -277,7 +402,7 @@ NeoPatternState NeoPatternService::getState() const {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
void NeoPatternService::registerTasks() {
|
||||
void NeoPatternService::registerTasks(TaskManager& taskManager) {
|
||||
taskManager.registerTask("neopattern_update", updateIntervalMs, [this]() { update(); });
|
||||
taskManager.registerTask("neopattern_status_print", 10000, [this]() {
|
||||
LOG_INFO("NeoPattern", "Status update");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
#include "spore/Service.h"
|
||||
#include "spore/core/TaskManager.h"
|
||||
#include "spore/core/NodeContext.h"
|
||||
#include "NeoPattern.h"
|
||||
#include "NeoPatternState.h"
|
||||
#include "NeoPixelConfig.h"
|
||||
@@ -25,10 +26,11 @@ public:
|
||||
REVERSE
|
||||
};
|
||||
|
||||
NeoPatternService(TaskManager& taskMgr, const NeoPixelConfig& config);
|
||||
NeoPatternService(NodeContext& ctx, TaskManager& taskMgr, const NeoPixelConfig& config);
|
||||
~NeoPatternService();
|
||||
|
||||
void registerEndpoints(ApiServer& api) override;
|
||||
void registerTasks(TaskManager& taskManager) override;
|
||||
const char* getName() const override { return "NeoPattern"; }
|
||||
|
||||
// Pattern control methods
|
||||
@@ -46,9 +48,10 @@ public:
|
||||
NeoPatternState getState() const;
|
||||
|
||||
private:
|
||||
void registerTasks();
|
||||
void registerPatterns();
|
||||
void update();
|
||||
void registerEventHandlers();
|
||||
bool applyControlParams(const JsonObject& obj);
|
||||
|
||||
// Pattern updaters
|
||||
void updateRainbowCycle();
|
||||
@@ -80,6 +83,7 @@ private:
|
||||
String getPatternDescription(const String& name) const;
|
||||
|
||||
TaskManager& taskManager;
|
||||
NodeContext& ctx;
|
||||
NeoPattern* neoPattern;
|
||||
NeoPixelConfig config;
|
||||
NeoPatternState currentState;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
#endif
|
||||
|
||||
#ifndef NEOPIXEL_LENGTH
|
||||
#define NEOPIXEL_LENGTH 8
|
||||
#define NEOPIXEL_LENGTH 16
|
||||
#endif
|
||||
|
||||
#ifndef NEOPIXEL_BRIGHTNESS
|
||||
@@ -45,8 +45,8 @@ void setup() {
|
||||
);
|
||||
|
||||
// Create and add custom service
|
||||
neoPatternService = new NeoPatternService(spore.getTaskManager(), config);
|
||||
spore.addService(neoPatternService);
|
||||
neoPatternService = new NeoPatternService(spore.getContext(), spore.getTaskManager(), config);
|
||||
spore.registerService(neoPatternService);
|
||||
|
||||
// Start the API server and complete initialization
|
||||
spore.begin();
|
||||
|
||||
127
examples/pixelstream/PixelStreamController.cpp
Normal file
127
examples/pixelstream/PixelStreamController.cpp
Normal file
@@ -0,0 +1,127 @@
|
||||
#include "PixelStreamController.h"
|
||||
|
||||
namespace {
|
||||
constexpr int COMPONENTS_PER_PIXEL = 3;
|
||||
}
|
||||
|
||||
PixelStreamController::PixelStreamController(NodeContext& ctxRef, const PixelStreamConfig& cfg)
|
||||
: ctx(ctxRef), config(cfg), pixels(cfg.pixelCount, cfg.pin, cfg.pixelType) {
|
||||
}
|
||||
|
||||
void PixelStreamController::begin() {
|
||||
pixels.begin();
|
||||
pixels.setBrightness(config.brightness);
|
||||
// Default all pixels to green so we can verify hardware before streaming frames
|
||||
for (uint16_t i = 0; i < config.pixelCount; ++i) {
|
||||
pixels.setPixelColor(i, pixels.Color(0, 255, 0));
|
||||
}
|
||||
pixels.show();
|
||||
|
||||
ctx.on("udp/raw", [this](void* data) {
|
||||
this->handleEvent(data);
|
||||
});
|
||||
|
||||
LOG_INFO("PixelStream", String("PixelStreamController ready on pin ") + String(config.pin) + " with " + String(config.pixelCount) + " pixels");
|
||||
}
|
||||
|
||||
void PixelStreamController::handleEvent(void* data) {
|
||||
if (data == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
String* payload = static_cast<String*>(data);
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!applyFrame(*payload)) {
|
||||
LOG_WARN("PixelStream", String("Ignoring RAW payload with invalid length (") + String(payload->length()) + ")");
|
||||
}
|
||||
}
|
||||
|
||||
bool PixelStreamController::applyFrame(const String& payload) {
|
||||
static constexpr std::size_t frameWidth = COMPONENTS_PER_PIXEL * 2;
|
||||
const std::size_t payloadLength = static_cast<std::size_t>(payload.length());
|
||||
|
||||
if (payloadLength == 0 || (payloadLength % frameWidth) != 0) {
|
||||
LOG_WARN("PixelStream", String("Payload size ") + String(payloadLength) + " is not a multiple of " + String(frameWidth));
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint16_t framesProvided = static_cast<uint16_t>(payloadLength / frameWidth);
|
||||
const uint16_t pixelsToUpdate = std::min(config.pixelCount, framesProvided);
|
||||
|
||||
for (uint16_t index = 0; index < pixelsToUpdate; ++index) {
|
||||
const std::size_t base = static_cast<std::size_t>(index) * frameWidth;
|
||||
FrameComponents components{};
|
||||
if (!tryParsePixel(payload, base, components)) {
|
||||
LOG_WARN("PixelStream", String("Invalid hex data at pixel index ") + String(index));
|
||||
return false;
|
||||
}
|
||||
const uint16_t hardwareIndex = mapPixelIndex(index);
|
||||
pixels.setPixelColor(hardwareIndex, pixels.Color(components.red, components.green, components.blue));
|
||||
}
|
||||
|
||||
// Clear any remaining pixels so stale data is removed when fewer frames are provided
|
||||
for (uint16_t index = pixelsToUpdate; index < config.pixelCount; ++index) {
|
||||
const uint16_t hardwareIndex = mapPixelIndex(index);
|
||||
pixels.setPixelColor(hardwareIndex, 0);
|
||||
}
|
||||
|
||||
pixels.show();
|
||||
return true;
|
||||
}
|
||||
|
||||
uint16_t PixelStreamController::mapPixelIndex(uint16_t logicalIndex) const {
|
||||
if (config.matrixWidth == 0) {
|
||||
return logicalIndex;
|
||||
}
|
||||
|
||||
const uint16_t row = logicalIndex / config.matrixWidth;
|
||||
const uint16_t col = logicalIndex % config.matrixWidth;
|
||||
|
||||
if (!config.matrixSerpentine || (row % 2 == 0)) {
|
||||
return row * config.matrixWidth + col;
|
||||
}
|
||||
|
||||
const uint16_t reversedCol = (config.matrixWidth - 1) - col;
|
||||
return row * config.matrixWidth + reversedCol;
|
||||
}
|
||||
|
||||
int PixelStreamController::hexToNibble(char c) {
|
||||
if (c >= '0' && c <= '9') {
|
||||
return c - '0';
|
||||
}
|
||||
if (c >= 'a' && c <= 'f') {
|
||||
return 10 + c - 'a';
|
||||
}
|
||||
if (c >= 'A' && c <= 'F') {
|
||||
return 10 + c - 'A';
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool PixelStreamController::tryParsePixel(const String& payload, std::size_t startIndex, FrameComponents& components) const {
|
||||
static constexpr std::size_t frameWidth = COMPONENTS_PER_PIXEL * 2;
|
||||
if (startIndex + frameWidth > static_cast<std::size_t>(payload.length())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const int rHi = hexToNibble(payload[startIndex]);
|
||||
const int rLo = hexToNibble(payload[startIndex + 1]);
|
||||
const int gHi = hexToNibble(payload[startIndex + 2]);
|
||||
const int gLo = hexToNibble(payload[startIndex + 3]);
|
||||
const int bHi = hexToNibble(payload[startIndex + 4]);
|
||||
const int bLo = hexToNibble(payload[startIndex + 5]);
|
||||
|
||||
if (rHi < 0 || rLo < 0 || gHi < 0 || gLo < 0 || bHi < 0 || bLo < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
components.red = static_cast<uint8_t>((rHi << 4) | rLo);
|
||||
components.green = static_cast<uint8_t>((gHi << 4) | gLo);
|
||||
components.blue = static_cast<uint8_t>((bHi << 4) | bLo);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
42
examples/pixelstream/PixelStreamController.h
Normal file
42
examples/pixelstream/PixelStreamController.h
Normal file
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Adafruit_NeoPixel.h>
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include "spore/core/NodeContext.h"
|
||||
#include "spore/util/Logging.h"
|
||||
|
||||
struct PixelStreamConfig {
|
||||
uint8_t pin;
|
||||
uint16_t pixelCount;
|
||||
uint8_t brightness;
|
||||
uint16_t matrixWidth;
|
||||
bool matrixSerpentine;
|
||||
neoPixelType pixelType;
|
||||
};
|
||||
|
||||
class PixelStreamController {
|
||||
public:
|
||||
PixelStreamController(NodeContext& ctx, const PixelStreamConfig& config);
|
||||
void begin();
|
||||
|
||||
private:
|
||||
struct FrameComponents {
|
||||
uint8_t red;
|
||||
uint8_t green;
|
||||
uint8_t blue;
|
||||
};
|
||||
|
||||
bool tryParsePixel(const String& payload, std::size_t startIndex, FrameComponents& components) const;
|
||||
void handleEvent(void* data);
|
||||
bool applyFrame(const String& payload);
|
||||
uint16_t mapPixelIndex(uint16_t logicalIndex) const;
|
||||
static int hexToNibble(char c);
|
||||
|
||||
NodeContext& ctx;
|
||||
PixelStreamConfig config;
|
||||
Adafruit_NeoPixel pixels;
|
||||
};
|
||||
|
||||
|
||||
33
examples/pixelstream/README.md
Normal file
33
examples/pixelstream/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# PixelStream Example
|
||||
|
||||
This example demonstrates how to consume the `udp/raw` cluster event and drive a NeoPixel strip or matrix directly from streamed RGB data. Frames are provided as hex encoded byte triplets (`RRGGBB` per pixel).
|
||||
|
||||
## Features
|
||||
|
||||
- Subscribes to `udp/raw` via `NodeContext::on`.
|
||||
- Converts incoming frames into pixel colors for strips or matrices.
|
||||
- Supports serpentine (zig-zag) matrix wiring.
|
||||
|
||||
## Payload Format
|
||||
|
||||
Each packet is expected to be `RAW:` followed by `pixelCount * 3 * 2` hexadecimal characters. For example, for 8 pixels:
|
||||
|
||||
```
|
||||
RAW:FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Strip Mode
|
||||
|
||||
Upload the example with `PIXEL_MATRIX_WIDTH` set to 0 (default). Send frames containing `PIXEL_COUNT * 3` bytes as hex.
|
||||
|
||||
### Matrix Mode
|
||||
|
||||
Set `PIXEL_MATRIX_WIDTH` to the number of columns. The controller remaps even/odd rows to support serpentine wiring.
|
||||
|
||||
## Configuration
|
||||
|
||||
Adjust `PIXEL_PIN`, `PIXEL_COUNT`, `PIXEL_BRIGHTNESS`, `PIXEL_MATRIX_WIDTH`, `PIXEL_MATRIX_SERPENTINE`, and `PIXEL_TYPE` through build defines or editing `main.cpp`.
|
||||
|
||||
|
||||
60
examples/pixelstream/main.cpp
Normal file
60
examples/pixelstream/main.cpp
Normal file
@@ -0,0 +1,60 @@
|
||||
#include <Arduino.h>
|
||||
#include "spore/Spore.h"
|
||||
#include "spore/util/Logging.h"
|
||||
#include "PixelStreamController.h"
|
||||
|
||||
#ifndef PIXEL_PIN
|
||||
#define PIXEL_PIN 2
|
||||
#endif
|
||||
|
||||
#ifndef PIXEL_COUNT
|
||||
#define PIXEL_COUNT 16
|
||||
#endif
|
||||
|
||||
#ifndef PIXEL_BRIGHTNESS
|
||||
#define PIXEL_BRIGHTNESS 80
|
||||
#endif
|
||||
|
||||
#ifndef PIXEL_MATRIX_WIDTH
|
||||
#define PIXEL_MATRIX_WIDTH 16
|
||||
#endif
|
||||
|
||||
#ifndef PIXEL_MATRIX_SERPENTINE
|
||||
#define PIXEL_MATRIX_SERPENTINE 0
|
||||
#endif
|
||||
|
||||
#ifndef PIXEL_TYPE
|
||||
#define PIXEL_TYPE NEO_GRB + NEO_KHZ800
|
||||
#endif
|
||||
|
||||
Spore spore({
|
||||
{"app", "pixelstream"},
|
||||
{"role", "led"},
|
||||
{"pixels", String(PIXEL_COUNT)}
|
||||
});
|
||||
|
||||
PixelStreamController* controller = nullptr;
|
||||
|
||||
void setup() {
|
||||
spore.setup();
|
||||
|
||||
PixelStreamConfig config{
|
||||
static_cast<uint8_t>(PIXEL_PIN),
|
||||
static_cast<uint16_t>(PIXEL_COUNT),
|
||||
static_cast<uint8_t>(PIXEL_BRIGHTNESS),
|
||||
static_cast<uint16_t>(PIXEL_MATRIX_WIDTH),
|
||||
static_cast<bool>(PIXEL_MATRIX_SERPENTINE),
|
||||
static_cast<neoPixelType>(PIXEL_TYPE)
|
||||
};
|
||||
|
||||
controller = new PixelStreamController(spore.getContext(), config);
|
||||
controller->begin();
|
||||
|
||||
spore.begin();
|
||||
}
|
||||
|
||||
void loop() {
|
||||
spore.loop();
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ void setup() {
|
||||
spore.setup();
|
||||
|
||||
relayService = new RelayService(spore.getContext(), spore.getTaskManager(), RELAY_PIN);
|
||||
spore.addService(relayService);
|
||||
spore.registerService(relayService);
|
||||
|
||||
spore.begin();
|
||||
}
|
||||
|
||||
@@ -7,15 +7,14 @@ RelayService::RelayService(NodeContext& ctx, TaskManager& taskMgr, int pin)
|
||||
pinMode(relayPin, OUTPUT);
|
||||
// Many relay modules are active LOW. Start in OFF state (relay de-energized).
|
||||
digitalWrite(relayPin, HIGH);
|
||||
registerTasks();
|
||||
}
|
||||
|
||||
void RelayService::registerEndpoints(ApiServer& api) {
|
||||
api.addEndpoint("/api/relay/status", HTTP_GET,
|
||||
api.registerEndpoint("/api/relay/status", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
api.addEndpoint("/api/relay", HTTP_POST,
|
||||
api.registerEndpoint("/api/relay", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleControlRequest(request); },
|
||||
std::vector<ParamSpec>{
|
||||
ParamSpec{String("state"), true, String("body"), String("string"),
|
||||
@@ -82,7 +81,7 @@ void RelayService::toggle() {
|
||||
}
|
||||
}
|
||||
|
||||
void RelayService::registerTasks() {
|
||||
void RelayService::registerTasks(TaskManager& taskManager) {
|
||||
taskManager.registerTask("relay_status_print", 5000, [this]() {
|
||||
LOG_INFO("RelayService", "Status - pin: " + String(relayPin) + ", state: " + (relayOn ? "ON" : "OFF"));
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ class RelayService : public Service {
|
||||
public:
|
||||
RelayService(NodeContext& ctx, TaskManager& taskMgr, int pin);
|
||||
void registerEndpoints(ApiServer& api) override;
|
||||
void registerTasks(TaskManager& taskManager) override;
|
||||
const char* getName() const override { return "Relay"; }
|
||||
|
||||
void turnOn();
|
||||
@@ -15,8 +16,6 @@ public:
|
||||
void toggle();
|
||||
|
||||
private:
|
||||
void registerTasks();
|
||||
|
||||
NodeContext& ctx;
|
||||
TaskManager& taskManager;
|
||||
int relayPin;
|
||||
|
||||
@@ -23,7 +23,7 @@ void setup() {
|
||||
|
||||
// Create and add custom service
|
||||
relayService = new RelayService(spore.getContext(), spore.getTaskManager(), RELAY_PIN);
|
||||
spore.addService(relayService);
|
||||
spore.registerService(relayService);
|
||||
|
||||
// Start the API server and complete initialization
|
||||
spore.begin();
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
#pragma once
|
||||
#include "spore/core/ApiServer.h"
|
||||
|
||||
class TaskManager;
|
||||
|
||||
class Service {
|
||||
public:
|
||||
virtual ~Service() = default;
|
||||
virtual void registerEndpoints(ApiServer& api) = 0;
|
||||
virtual void registerTasks(TaskManager& taskManager) = 0;
|
||||
virtual const char* getName() const = 0;
|
||||
};
|
||||
|
||||
@@ -25,8 +25,8 @@ public:
|
||||
void loop();
|
||||
|
||||
// Service management
|
||||
void addService(std::shared_ptr<Service> service);
|
||||
void addService(Service* service);
|
||||
void registerService(std::shared_ptr<Service> service);
|
||||
void registerService(Service* service);
|
||||
|
||||
// Access to core components
|
||||
NodeContext& getContext() { return ctx; }
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <ESPAsyncWebServer.h>
|
||||
#include <AsyncWebSocket.h>
|
||||
#include <Updater.h>
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
@@ -18,14 +19,14 @@ class ApiServer {
|
||||
public:
|
||||
ApiServer(NodeContext& ctx, TaskManager& taskMgr, uint16_t port = 80);
|
||||
void begin();
|
||||
void addService(Service& service);
|
||||
void addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler);
|
||||
void addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
void registerService(Service& service);
|
||||
void registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler);
|
||||
void registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
std::function<void(AsyncWebServerRequest*, const String&, size_t, uint8_t*, size_t, bool)> uploadHandler);
|
||||
|
||||
void addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
void registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
const std::vector<ParamSpec>& params);
|
||||
void addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
void registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
std::function<void(AsyncWebServerRequest*, const String&, size_t, uint8_t*, size_t, bool)> uploadHandler,
|
||||
const std::vector<ParamSpec>& params);
|
||||
|
||||
@@ -39,13 +40,18 @@ public:
|
||||
|
||||
private:
|
||||
AsyncWebServer server;
|
||||
AsyncWebSocket ws{ "/ws" };
|
||||
NodeContext& ctx;
|
||||
TaskManager& taskManager;
|
||||
std::vector<std::reference_wrapper<Service>> services;
|
||||
std::vector<EndpointInfo> endpoints; // Single source of truth for endpoints
|
||||
std::vector<AsyncWebSocketClient*> wsClients;
|
||||
|
||||
// Internal helpers
|
||||
void registerEndpoint(const String& uri, int method,
|
||||
const std::vector<ParamSpec>& params,
|
||||
const String& serviceName);
|
||||
|
||||
// WebSocket helpers
|
||||
void setupWebSocket();
|
||||
};
|
||||
|
||||
@@ -7,13 +7,15 @@
|
||||
#include <ArduinoJson.h>
|
||||
#include <ESP8266HTTPClient.h>
|
||||
#include <map>
|
||||
#include <vector>
|
||||
#include <functional>
|
||||
|
||||
class ClusterManager {
|
||||
public:
|
||||
ClusterManager(NodeContext& ctx, TaskManager& taskMgr);
|
||||
void registerTasks();
|
||||
void sendDiscovery();
|
||||
void listenForDiscovery();
|
||||
void listen();
|
||||
void addOrUpdateNode(const String& nodeHost, IPAddress nodeIP);
|
||||
void updateAllNodeStatuses();
|
||||
void removeDeadNodes();
|
||||
@@ -26,4 +28,25 @@ public:
|
||||
private:
|
||||
NodeContext& ctx;
|
||||
TaskManager& taskManager;
|
||||
struct MessageHandler {
|
||||
bool (*predicate)(const char*);
|
||||
std::function<void(const char*)> handle;
|
||||
const char* name;
|
||||
};
|
||||
void initMessageHandlers();
|
||||
void handleIncomingMessage(const char* incoming);
|
||||
static bool isDiscoveryMsg(const char* msg);
|
||||
static bool isHeartbeatMsg(const char* msg);
|
||||
static bool isResponseMsg(const char* msg);
|
||||
static bool isNodeInfoMsg(const char* msg);
|
||||
static bool isClusterEventMsg(const char* msg);
|
||||
static bool isRawMsg(const char* msg);
|
||||
void onDiscovery(const char* msg);
|
||||
void onHeartbeat(const char* msg);
|
||||
void onResponse(const char* msg);
|
||||
void onNodeInfo(const char* msg);
|
||||
void onClusterEvent(const char* msg);
|
||||
void onRawMessage(const char* msg);
|
||||
unsigned long lastHeartbeatSentAt = 0;
|
||||
std::vector<MessageHandler> messageHandlers;
|
||||
};
|
||||
|
||||
@@ -19,11 +19,14 @@ public:
|
||||
IPAddress localIP;
|
||||
NodeInfo self;
|
||||
std::map<String, NodeInfo>* memberList;
|
||||
Config config;
|
||||
::Config config;
|
||||
|
||||
using EventCallback = std::function<void(void*)>;
|
||||
std::map<std::string, std::vector<EventCallback>> eventRegistry;
|
||||
using AnyEventCallback = std::function<void(const std::string&, void*)>;
|
||||
std::vector<AnyEventCallback> anyEventSubscribers;
|
||||
|
||||
void on(const std::string& event, EventCallback cb);
|
||||
void fire(const std::string& event, void* data);
|
||||
void onAny(AnyEventCallback cb);
|
||||
};
|
||||
|
||||
@@ -7,8 +7,13 @@
|
||||
namespace ClusterProtocol {
|
||||
constexpr const char* DISCOVERY_MSG = "CLUSTER_DISCOVERY";
|
||||
constexpr const char* RESPONSE_MSG = "CLUSTER_RESPONSE";
|
||||
constexpr const char* HEARTBEAT_MSG = "CLUSTER_HEARTBEAT";
|
||||
constexpr const char* NODE_INFO_MSG = "CLUSTER_NODE_INFO";
|
||||
constexpr const char* CLUSTER_EVENT_MSG = "CLUSTER_EVENT";
|
||||
constexpr const char* RAW_MSG = "RAW";
|
||||
constexpr uint16_t UDP_PORT = 4210;
|
||||
constexpr size_t UDP_BUF_SIZE = 64;
|
||||
// Increased buffer to accommodate larger RAW pixel streams and node info JSON over UDP
|
||||
constexpr size_t UDP_BUF_SIZE = 2048;
|
||||
constexpr const char* API_NODE_STATUS = "/api/node/status";
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ class ClusterService : public Service {
|
||||
public:
|
||||
ClusterService(NodeContext& ctx);
|
||||
void registerEndpoints(ApiServer& api) override;
|
||||
void registerTasks(TaskManager& taskManager) override;
|
||||
const char* getName() const override { return "Cluster"; }
|
||||
|
||||
private:
|
||||
|
||||
@@ -7,6 +7,7 @@ class MonitoringService : public Service {
|
||||
public:
|
||||
MonitoringService(CpuUsage& cpuUsage);
|
||||
void registerEndpoints(ApiServer& api) override;
|
||||
void registerTasks(TaskManager& taskManager) override;
|
||||
const char* getName() const override { return "Monitoring"; }
|
||||
|
||||
// System resource information
|
||||
|
||||
@@ -7,6 +7,7 @@ class NetworkService : public Service {
|
||||
public:
|
||||
NetworkService(NetworkManager& networkManager);
|
||||
void registerEndpoints(ApiServer& api) override;
|
||||
void registerTasks(TaskManager& taskManager) override;
|
||||
const char* getName() const override { return "Network"; }
|
||||
|
||||
private:
|
||||
|
||||
@@ -8,6 +8,7 @@ class NodeService : public Service {
|
||||
public:
|
||||
NodeService(NodeContext& ctx, ApiServer& apiServer);
|
||||
void registerEndpoints(ApiServer& api) override;
|
||||
void registerTasks(TaskManager& taskManager) override;
|
||||
const char* getName() const override { return "Node"; }
|
||||
|
||||
private:
|
||||
|
||||
@@ -9,6 +9,7 @@ class StaticFileService : public Service {
|
||||
public:
|
||||
StaticFileService(NodeContext& ctx, ApiServer& apiServer);
|
||||
void registerEndpoints(ApiServer& api) override;
|
||||
void registerTasks(TaskManager& taskManager) override;
|
||||
const char* getName() const override { return name.c_str(); }
|
||||
|
||||
private:
|
||||
|
||||
@@ -7,6 +7,7 @@ class TaskService : public Service {
|
||||
public:
|
||||
TaskService(TaskManager& taskManager);
|
||||
void registerEndpoints(ApiServer& api) override;
|
||||
void registerTasks(TaskManager& taskManager) override;
|
||||
const char* getName() const override { return "Task"; }
|
||||
|
||||
private:
|
||||
|
||||
@@ -15,6 +15,7 @@ public:
|
||||
// Cluster Configuration
|
||||
unsigned long discovery_interval_ms;
|
||||
unsigned long heartbeat_interval_ms;
|
||||
unsigned long cluster_listen_interval_ms;
|
||||
unsigned long status_update_interval_ms;
|
||||
unsigned long member_info_update_interval_ms;
|
||||
unsigned long print_interval_ms;
|
||||
|
||||
@@ -17,7 +17,7 @@ struct NodeInfo {
|
||||
uint32_t cpuFreqMHz = 0;
|
||||
uint32_t flashChipSize = 0;
|
||||
} resources;
|
||||
unsigned long latency = 0; // ms since lastSeen
|
||||
unsigned long latency = 0; // ms from heartbeat broadcast to NODE_INFO receipt
|
||||
std::vector<EndpointInfo> endpoints; // List of registered endpoints
|
||||
std::map<String, String> labels; // Arbitrary node labels (key -> value)
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
; https://docs.platformio.org/page/projectconf.html
|
||||
|
||||
[platformio]
|
||||
default_envs = base
|
||||
;default_envs = base
|
||||
src_dir = .
|
||||
data_dir = ${PROJECT_DIR}/examples/${PIOENV}/data
|
||||
|
||||
@@ -100,3 +100,68 @@ build_src_filter =
|
||||
+<src/spore/types/*.cpp>
|
||||
+<src/spore/util/*.cpp>
|
||||
+<src/internal/*.cpp>
|
||||
|
||||
[env:pixelstream]
|
||||
platform = platformio/espressif8266@^4.2.1
|
||||
board = esp01_1m
|
||||
framework = arduino
|
||||
upload_speed = 115200
|
||||
monitor_speed = 115200
|
||||
board_build.filesystem = littlefs
|
||||
board_build.flash_mode = dout
|
||||
board_build.ldscript = eagle.flash.1m64.ld
|
||||
lib_deps = ${common.lib_deps}
|
||||
adafruit/Adafruit NeoPixel@^1.15.1
|
||||
build_flags =
|
||||
build_src_filter =
|
||||
+<examples/pixelstream/*.cpp>
|
||||
+<src/spore/*.cpp>
|
||||
+<src/spore/core/*.cpp>
|
||||
+<src/spore/services/*.cpp>
|
||||
+<src/spore/types/*.cpp>
|
||||
+<src/spore/util/*.cpp>
|
||||
+<src/internal/*.cpp>
|
||||
|
||||
[env:pixelstream_d1]
|
||||
platform = platformio/espressif8266@^4.2.1
|
||||
board = d1_mini
|
||||
framework = arduino
|
||||
upload_speed = 115200
|
||||
monitor_speed = 115200
|
||||
board_build.filesystem = littlefs
|
||||
board_build.flash_mode = dout
|
||||
board_build.ldscript = eagle.flash.4m1m.ld
|
||||
lib_deps = ${common.lib_deps}
|
||||
adafruit/Adafruit NeoPixel@^1.15.1
|
||||
build_flags = -DPIXEL_PIN=TX -DPIXEL_COUNT=256 -DMATRIX_WIDTH=16
|
||||
build_src_filter =
|
||||
+<examples/pixelstream/*.cpp>
|
||||
+<src/spore/*.cpp>
|
||||
+<src/spore/core/*.cpp>
|
||||
+<src/spore/services/*.cpp>
|
||||
+<src/spore/types/*.cpp>
|
||||
+<src/spore/util/*.cpp>
|
||||
+<src/internal/*.cpp>
|
||||
|
||||
[env:multimatrix]
|
||||
platform = platformio/espressif8266@^4.2.1
|
||||
board = d1_mini
|
||||
framework = arduino
|
||||
upload_speed = 115200
|
||||
monitor_speed = 115200
|
||||
board_build.filesystem = littlefs
|
||||
board_build.flash_mode = dio
|
||||
board_build.flash_size = 4M
|
||||
board_build.ldscript = eagle.flash.4m1m.ld
|
||||
lib_deps = ${common.lib_deps}
|
||||
adafruit/Adafruit NeoPixel@^1.15.1
|
||||
dfrobot/DFRobotDFPlayerMini@^1.0.6
|
||||
build_src_filter =
|
||||
+<examples/multimatrix/*.cpp>
|
||||
+<examples/pixelstream/PixelStreamController.cpp>
|
||||
+<src/spore/*.cpp>
|
||||
+<src/spore/core/*.cpp>
|
||||
+<src/spore/services/*.cpp>
|
||||
+<src/spore/types/*.cpp>
|
||||
+<src/spore/util/*.cpp>
|
||||
+<src/internal/*.cpp>
|
||||
|
||||
@@ -85,7 +85,7 @@ void Spore::loop() {
|
||||
yield();
|
||||
}
|
||||
|
||||
void Spore::addService(std::shared_ptr<Service> service) {
|
||||
void Spore::registerService(std::shared_ptr<Service> service) {
|
||||
if (!service) {
|
||||
LOG_WARN("Spore", "Attempted to add null service");
|
||||
return;
|
||||
@@ -95,21 +95,22 @@ void Spore::addService(std::shared_ptr<Service> service) {
|
||||
|
||||
if (apiServerStarted) {
|
||||
// If API server is already started, register the service immediately
|
||||
apiServer.addService(*service);
|
||||
LOG_INFO("Spore", "Added service '" + String(service->getName()) + "' to running API server");
|
||||
apiServer.registerService(*service);
|
||||
service->registerTasks(taskManager);
|
||||
LOG_INFO("Spore", "Added service '" + String(service->getName()) + "' to running API server and task manager");
|
||||
} else {
|
||||
LOG_INFO("Spore", "Registered service '" + String(service->getName()) + "' (will be added to API server when begin() is called)");
|
||||
}
|
||||
}
|
||||
|
||||
void Spore::addService(Service* service) {
|
||||
void Spore::registerService(Service* service) {
|
||||
if (!service) {
|
||||
LOG_WARN("Spore", "Attempted to add null service");
|
||||
return;
|
||||
}
|
||||
|
||||
// Wrap raw pointer in shared_ptr with no-op deleter to avoid double-delete
|
||||
addService(std::shared_ptr<Service>(service, [](Service*){}));
|
||||
registerService(std::shared_ptr<Service>(service, [](Service*){}));
|
||||
}
|
||||
|
||||
|
||||
@@ -155,11 +156,12 @@ void Spore::startApiServer() {
|
||||
|
||||
LOG_INFO("Spore", "Starting API server...");
|
||||
|
||||
// Register all services with API server
|
||||
// Register all services with API server and task manager
|
||||
for (auto& service : services) {
|
||||
if (service) {
|
||||
apiServer.addService(*service);
|
||||
LOG_INFO("Spore", "Added service '" + String(service->getName()) + "' to API server");
|
||||
apiServer.registerService(*service);
|
||||
service->registerTasks(taskManager);
|
||||
LOG_INFO("Spore", "Added service '" + String(service->getName()) + "' to API server and task manager");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ void ApiServer::registerEndpoint(const String& uri, int method,
|
||||
}
|
||||
}
|
||||
|
||||
void ApiServer::addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler) {
|
||||
void ApiServer::registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler) {
|
||||
// Get current service name if available
|
||||
String serviceName = "unknown";
|
||||
if (!services.empty()) {
|
||||
@@ -41,7 +41,7 @@ void ApiServer::addEndpoint(const String& uri, int method, std::function<void(As
|
||||
server.on(uri.c_str(), method, requestHandler);
|
||||
}
|
||||
|
||||
void ApiServer::addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
void ApiServer::registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
std::function<void(AsyncWebServerRequest*, const String&, size_t, uint8_t*, size_t, bool)> uploadHandler) {
|
||||
// Get current service name if available
|
||||
String serviceName = "unknown";
|
||||
@@ -53,7 +53,7 @@ void ApiServer::addEndpoint(const String& uri, int method, std::function<void(As
|
||||
}
|
||||
|
||||
// Overloads that also record minimal capability specs
|
||||
void ApiServer::addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
void ApiServer::registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
const std::vector<ParamSpec>& params) {
|
||||
// Get current service name if available
|
||||
String serviceName = "unknown";
|
||||
@@ -64,7 +64,7 @@ void ApiServer::addEndpoint(const String& uri, int method, std::function<void(As
|
||||
server.on(uri.c_str(), method, requestHandler);
|
||||
}
|
||||
|
||||
void ApiServer::addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
void ApiServer::registerEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||
std::function<void(AsyncWebServerRequest*, const String&, size_t, uint8_t*, size_t, bool)> uploadHandler,
|
||||
const std::vector<ParamSpec>& params) {
|
||||
// Get current service name if available
|
||||
@@ -76,7 +76,7 @@ void ApiServer::addEndpoint(const String& uri, int method, std::function<void(As
|
||||
server.on(uri.c_str(), method, requestHandler, uploadHandler);
|
||||
}
|
||||
|
||||
void ApiServer::addService(Service& service) {
|
||||
void ApiServer::registerService(Service& service) {
|
||||
services.push_back(service);
|
||||
LOG_INFO("API", "Added service: " + String(service.getName()));
|
||||
}
|
||||
@@ -87,6 +87,10 @@ void ApiServer::serveStatic(const String& uri, fs::FS& fs, const String& path, c
|
||||
}
|
||||
|
||||
void ApiServer::begin() {
|
||||
// Setup streaming API (WebSocket)
|
||||
setupWebSocket();
|
||||
server.addHandler(&ws);
|
||||
|
||||
// Register all service endpoints
|
||||
for (auto& service : services) {
|
||||
service.get().registerEndpoints(*this);
|
||||
@@ -95,3 +99,95 @@ void ApiServer::begin() {
|
||||
|
||||
server.begin();
|
||||
}
|
||||
|
||||
void ApiServer::setupWebSocket() {
|
||||
ws.onEvent([this](AsyncWebSocket* server, AsyncWebSocketClient* client, AwsEventType type, void* arg, uint8_t* data, size_t len) {
|
||||
if (type == WS_EVT_DATA) {
|
||||
AwsFrameInfo* info = (AwsFrameInfo*)arg;
|
||||
if (info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT) {
|
||||
// Parse directly from the raw buffer with explicit length
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, (const char*)data, len);
|
||||
if (!err) {
|
||||
LOG_DEBUG("API", "Received event: " + String(doc["event"].as<String>()));
|
||||
String eventName = doc["event"].as<String>();
|
||||
String payloadStr;
|
||||
if (doc["payload"].is<const char*>()) {
|
||||
payloadStr = doc["payload"].as<const char*>();
|
||||
} else if (!doc["payload"].isNull()) {
|
||||
// If payload is an object/array, serialize it
|
||||
String tmp; serializeJson(doc["payload"], tmp); payloadStr = tmp;
|
||||
}
|
||||
// Allow empty payload; services may treat it as defaults
|
||||
if (eventName.length() > 0) {
|
||||
// Inject origin tag into payload JSON if possible
|
||||
String enriched = payloadStr;
|
||||
if (payloadStr.length() > 0) {
|
||||
JsonDocument pd;
|
||||
if (!deserializeJson(pd, payloadStr)) {
|
||||
pd["_origin"] = String("ws:") + String(client->id());
|
||||
String tmp; serializeJson(pd, tmp); enriched = tmp;
|
||||
} else {
|
||||
// If payload is plain string, leave as-is (no origin)
|
||||
}
|
||||
}
|
||||
std::string ev = eventName.c_str();
|
||||
ctx.fire(ev, &enriched);
|
||||
// Acknowledge
|
||||
client->text("{\"ok\":true}");
|
||||
} else {
|
||||
client->text("{\"error\":\"Missing 'event'\"}");
|
||||
}
|
||||
} else {
|
||||
client->text("{\"error\":\"Invalid JSON\"}");
|
||||
}
|
||||
}
|
||||
} else if (type == WS_EVT_CONNECT) {
|
||||
client->text("{\"hello\":\"ws connected\"}");
|
||||
wsClients.push_back(client);
|
||||
} else if (type == WS_EVT_DISCONNECT) {
|
||||
wsClients.erase(std::remove(wsClients.begin(), wsClients.end(), client), wsClients.end());
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to all local events and forward to websocket clients
|
||||
ctx.onAny([this](const std::string& event, void* dataPtr) {
|
||||
// Ignore raw UDP frames
|
||||
if (event == "udp/raw") {
|
||||
return;
|
||||
}
|
||||
|
||||
String* payloadStrPtr = static_cast<String*>(dataPtr);
|
||||
String payloadStr = payloadStrPtr ? *payloadStrPtr : String("");
|
||||
|
||||
// Extract and strip origin if present
|
||||
String origin;
|
||||
String cleanedPayload = payloadStr;
|
||||
if (payloadStr.length() > 0) {
|
||||
JsonDocument pd;
|
||||
if (!deserializeJson(pd, payloadStr)) {
|
||||
if (pd["_origin"].is<const char*>()) {
|
||||
origin = pd["_origin"].as<const char*>();
|
||||
pd.remove("_origin");
|
||||
String tmp; serializeJson(pd, tmp); cleanedPayload = tmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
JsonDocument outDoc;
|
||||
outDoc["event"] = event.c_str();
|
||||
outDoc["payload"] = cleanedPayload;
|
||||
String out; serializeJson(outDoc, out);
|
||||
|
||||
if (origin.startsWith("ws:")) {
|
||||
uint32_t originId = (uint32_t)origin.substring(3).toInt();
|
||||
for (auto* c : wsClients) {
|
||||
if (c && c->id() != originId) {
|
||||
c->text(out);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ws.textAll(out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,17 +8,35 @@ ClusterManager::ClusterManager(NodeContext& ctx, TaskManager& taskMgr) : ctx(ctx
|
||||
NodeInfo* node = static_cast<NodeInfo*>(data);
|
||||
this->addOrUpdateNode(node->hostname, node->ip);
|
||||
});
|
||||
// Centralized broadcast handler: services fire 'cluster/broadcast' with CLUSTER_EVENT JSON payload
|
||||
ctx.on("cluster/broadcast", [this](void* data) {
|
||||
String* jsonStr = static_cast<String*>(data);
|
||||
if (!jsonStr) {
|
||||
LOG_WARN("Cluster", "cluster/broadcast called with null data");
|
||||
return;
|
||||
}
|
||||
// Subnet-directed broadcast (more reliable than 255.255.255.255 on some networks)
|
||||
IPAddress ip = WiFi.localIP();
|
||||
IPAddress mask = WiFi.subnetMask();
|
||||
IPAddress bcast(ip[0] | ~mask[0], ip[1] | ~mask[1], ip[2] | ~mask[2], ip[3] | ~mask[3]);
|
||||
LOG_DEBUG("Cluster", String("Broadcasting CLUSTER_EVENT to ") + bcast.toString() + " len=" + String(jsonStr->length()));
|
||||
this->ctx.udp->beginPacket(bcast, this->ctx.config.udp_port);
|
||||
String msg = String(ClusterProtocol::CLUSTER_EVENT_MSG) + ":" + *jsonStr;
|
||||
this->ctx.udp->write(msg.c_str());
|
||||
this->ctx.udp->endPacket();
|
||||
});
|
||||
// Register tasks
|
||||
registerTasks();
|
||||
initMessageHandlers();
|
||||
}
|
||||
|
||||
void ClusterManager::registerTasks() {
|
||||
taskManager.registerTask("discovery_send", ctx.config.discovery_interval_ms, [this]() { sendDiscovery(); });
|
||||
taskManager.registerTask("discovery_listen", ctx.config.discovery_interval_ms / 10, [this]() { listenForDiscovery(); });
|
||||
taskManager.registerTask("cluster_discovery", ctx.config.discovery_interval_ms, [this]() { sendDiscovery(); });
|
||||
taskManager.registerTask("cluster_listen", ctx.config.cluster_listen_interval_ms, [this]() { listen(); });
|
||||
taskManager.registerTask("status_update", ctx.config.status_update_interval_ms, [this]() { updateAllNodeStatuses(); removeDeadNodes(); });
|
||||
taskManager.registerTask("print_members", ctx.config.print_interval_ms, [this]() { printMemberList(); });
|
||||
taskManager.registerTask("heartbeat", ctx.config.heartbeat_interval_ms, [this]() { heartbeatTaskCallback(); });
|
||||
taskManager.registerTask("update_members_info", ctx.config.member_info_update_interval_ms, [this]() { updateAllMembersInfoTaskCallback(); });
|
||||
taskManager.registerTask("cluster_update_members_info", ctx.config.member_info_update_interval_ms, [this]() { updateAllMembersInfoTaskCallback(); });
|
||||
LOG_INFO("ClusterManager", "Registered all cluster tasks");
|
||||
}
|
||||
|
||||
@@ -29,28 +47,224 @@ void ClusterManager::sendDiscovery() {
|
||||
ctx.udp->endPacket();
|
||||
}
|
||||
|
||||
void ClusterManager::listenForDiscovery() {
|
||||
void ClusterManager::listen() {
|
||||
int packetSize = ctx.udp->parsePacket();
|
||||
if (packetSize) {
|
||||
char incoming[ClusterProtocol::UDP_BUF_SIZE];
|
||||
int len = ctx.udp->read(incoming, ClusterProtocol::UDP_BUF_SIZE);
|
||||
if (len > 0) {
|
||||
incoming[len] = 0;
|
||||
}
|
||||
//LOG_DEBUG(ctx, "UDP", "Packet received: " + String(incoming));
|
||||
if (strcmp(incoming, ClusterProtocol::DISCOVERY_MSG) == 0) {
|
||||
//LOG_DEBUG(ctx, "UDP", "Discovery request from: " + ctx.udp->remoteIP().toString());
|
||||
ctx.udp->beginPacket(ctx.udp->remoteIP(), ctx.config.udp_port);
|
||||
String response = String(ClusterProtocol::RESPONSE_MSG) + ":" + ctx.hostname;
|
||||
ctx.udp->write(response.c_str());
|
||||
ctx.udp->endPacket();
|
||||
//LOG_DEBUG(ctx, "UDP", "Sent response with hostname: " + ctx.hostname);
|
||||
} else if (strncmp(incoming, ClusterProtocol::RESPONSE_MSG, strlen(ClusterProtocol::RESPONSE_MSG)) == 0) {
|
||||
char* hostPtr = incoming + strlen(ClusterProtocol::RESPONSE_MSG) + 1;
|
||||
String nodeHost = String(hostPtr);
|
||||
addOrUpdateNode(nodeHost, ctx.udp->remoteIP());
|
||||
if (!packetSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
char incoming[ClusterProtocol::UDP_BUF_SIZE];
|
||||
int len = ctx.udp->read(incoming, ClusterProtocol::UDP_BUF_SIZE);
|
||||
if (len <= 0) {
|
||||
return;
|
||||
}
|
||||
if (len >= (int)ClusterProtocol::UDP_BUF_SIZE) {
|
||||
incoming[ClusterProtocol::UDP_BUF_SIZE - 1] = 0;
|
||||
} else {
|
||||
incoming[len] = 0;
|
||||
}
|
||||
handleIncomingMessage(incoming);
|
||||
}
|
||||
|
||||
void ClusterManager::initMessageHandlers() {
|
||||
messageHandlers.clear();
|
||||
messageHandlers.push_back({ &ClusterManager::isRawMsg, [this](const char* msg){ this->onRawMessage(msg); }, "RAW" });
|
||||
messageHandlers.push_back({ &ClusterManager::isDiscoveryMsg, [this](const char* msg){ this->onDiscovery(msg); }, "DISCOVERY" });
|
||||
messageHandlers.push_back({ &ClusterManager::isHeartbeatMsg, [this](const char* msg){ this->onHeartbeat(msg); }, "HEARTBEAT" });
|
||||
messageHandlers.push_back({ &ClusterManager::isResponseMsg, [this](const char* msg){ this->onResponse(msg); }, "RESPONSE" });
|
||||
messageHandlers.push_back({ &ClusterManager::isNodeInfoMsg, [this](const char* msg){ this->onNodeInfo(msg); }, "NODE_INFO" });
|
||||
messageHandlers.push_back({ &ClusterManager::isClusterEventMsg, [this](const char* msg){ this->onClusterEvent(msg); }, "CLUSTER_EVENT" });
|
||||
}
|
||||
|
||||
void ClusterManager::handleIncomingMessage(const char* incoming) {
|
||||
for (const auto& h : messageHandlers) {
|
||||
if (h.predicate(incoming)) {
|
||||
h.handle(incoming);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Unknown message - log first token
|
||||
const char* colon = strchr(incoming, ':');
|
||||
String head;
|
||||
if (colon) {
|
||||
head = String(incoming).substring(0, colon - incoming);
|
||||
} else {
|
||||
head = String(incoming);
|
||||
}
|
||||
LOG_DEBUG("Cluster", String("Unknown cluster message: ") + head);
|
||||
}
|
||||
|
||||
bool ClusterManager::isDiscoveryMsg(const char* msg) {
|
||||
return strcmp(msg, ClusterProtocol::DISCOVERY_MSG) == 0;
|
||||
}
|
||||
|
||||
bool ClusterManager::isHeartbeatMsg(const char* msg) {
|
||||
return strncmp(msg, ClusterProtocol::HEARTBEAT_MSG, strlen(ClusterProtocol::HEARTBEAT_MSG)) == 0;
|
||||
}
|
||||
|
||||
bool ClusterManager::isResponseMsg(const char* msg) {
|
||||
return strncmp(msg, ClusterProtocol::RESPONSE_MSG, strlen(ClusterProtocol::RESPONSE_MSG)) == 0;
|
||||
}
|
||||
|
||||
bool ClusterManager::isNodeInfoMsg(const char* msg) {
|
||||
return strncmp(msg, ClusterProtocol::NODE_INFO_MSG, strlen(ClusterProtocol::NODE_INFO_MSG)) == 0;
|
||||
}
|
||||
|
||||
bool ClusterManager::isClusterEventMsg(const char* msg) {
|
||||
return strncmp(msg, ClusterProtocol::CLUSTER_EVENT_MSG, strlen(ClusterProtocol::CLUSTER_EVENT_MSG)) == 0;
|
||||
}
|
||||
|
||||
bool ClusterManager::isRawMsg(const char* msg) {
|
||||
// RAW frames must be "RAW:<payload>"; enforce the delimiter so we skip things like "RAW_HEARTBEAT".
|
||||
const std::size_t prefixLen = strlen(ClusterProtocol::RAW_MSG);
|
||||
if (strncmp(msg, ClusterProtocol::RAW_MSG, prefixLen) != 0) {
|
||||
return false;
|
||||
}
|
||||
return msg[prefixLen] == ':';
|
||||
}
|
||||
|
||||
void ClusterManager::onDiscovery(const char* /*msg*/) {
|
||||
ctx.udp->beginPacket(ctx.udp->remoteIP(), ctx.config.udp_port);
|
||||
String response = String(ClusterProtocol::RESPONSE_MSG) + ":" + ctx.hostname;
|
||||
ctx.udp->write(response.c_str());
|
||||
ctx.udp->endPacket();
|
||||
}
|
||||
|
||||
void ClusterManager::onHeartbeat(const char* /*msg*/) {
|
||||
JsonDocument doc;
|
||||
|
||||
if (ctx.memberList) {
|
||||
auto it = ctx.memberList->find(ctx.hostname);
|
||||
if (it != ctx.memberList->end()) {
|
||||
JsonObject labelsObj = doc["labels"].to<JsonObject>();
|
||||
for (const auto& kv : it->second.labels) {
|
||||
labelsObj[kv.first.c_str()] = kv.second;
|
||||
}
|
||||
} else if (!ctx.self.labels.empty()) {
|
||||
JsonObject labelsObj = doc["labels"].to<JsonObject>();
|
||||
for (const auto& kv : ctx.self.labels) {
|
||||
labelsObj[kv.first.c_str()] = kv.second;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
|
||||
ctx.udp->beginPacket(ctx.udp->remoteIP(), ctx.config.udp_port);
|
||||
String msg = String(ClusterProtocol::NODE_INFO_MSG) + ":" + ctx.hostname + ":" + json;
|
||||
ctx.udp->write(msg.c_str());
|
||||
ctx.udp->endPacket();
|
||||
}
|
||||
|
||||
void ClusterManager::onResponse(const char* msg) {
|
||||
char* hostPtr = const_cast<char*>(msg) + strlen(ClusterProtocol::RESPONSE_MSG) + 1;
|
||||
String nodeHost = String(hostPtr);
|
||||
addOrUpdateNode(nodeHost, ctx.udp->remoteIP());
|
||||
}
|
||||
|
||||
void ClusterManager::onNodeInfo(const char* msg) {
|
||||
char* p = const_cast<char*>(msg) + strlen(ClusterProtocol::NODE_INFO_MSG) + 1;
|
||||
char* hostEnd = strchr(p, ':');
|
||||
if (hostEnd) {
|
||||
*hostEnd = '\0';
|
||||
const char* hostCStr = p;
|
||||
const char* jsonCStr = hostEnd + 1;
|
||||
|
||||
String nodeHost = String(hostCStr);
|
||||
IPAddress senderIP = ctx.udp->remoteIP();
|
||||
|
||||
addOrUpdateNode(nodeHost, senderIP);
|
||||
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, jsonCStr);
|
||||
if (!err) {
|
||||
auto& memberList = *ctx.memberList;
|
||||
auto it = memberList.find(nodeHost);
|
||||
if (it != memberList.end()) {
|
||||
NodeInfo& node = it->second;
|
||||
node.status = NodeInfo::ACTIVE;
|
||||
unsigned long now = millis();
|
||||
node.lastSeen = now;
|
||||
if (lastHeartbeatSentAt != 0) {
|
||||
node.latency = now - lastHeartbeatSentAt;
|
||||
}
|
||||
|
||||
node.labels.clear();
|
||||
if (doc["labels"].is<JsonObject>()) {
|
||||
JsonObject labelsObj = doc["labels"].as<JsonObject>();
|
||||
for (JsonPair kvp : labelsObj) {
|
||||
const char* key = kvp.key().c_str();
|
||||
const char* value = labelsObj[kvp.key()];
|
||||
node.labels[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LOG_WARN("Cluster", String("Failed to parse NODE_INFO JSON from ") + senderIP.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ClusterManager::onClusterEvent(const char* msg) {
|
||||
// Message format: CLUSTER_EVENT:{"event":"...","data":"<json string>"}
|
||||
const char* jsonStart = msg + strlen(ClusterProtocol::CLUSTER_EVENT_MSG) + 1; // skip prefix and ':'
|
||||
if (*jsonStart == '\0') {
|
||||
LOG_DEBUG("Cluster", "CLUSTER_EVENT received with empty payload");
|
||||
return;
|
||||
}
|
||||
LOG_DEBUG("Cluster", String("CLUSTER_EVENT raw from ") + ctx.udp->remoteIP().toString() + " len=" + String(strlen(jsonStart)));
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, jsonStart);
|
||||
if (err) {
|
||||
LOG_ERROR("Cluster", String("Failed to parse CLUSTER_EVENT JSON from ") + ctx.udp->remoteIP().toString());
|
||||
return;
|
||||
}
|
||||
// Robust extraction of event and data
|
||||
String eventStr;
|
||||
if (doc["event"].is<const char*>()) {
|
||||
eventStr = doc["event"].as<const char*>();
|
||||
} else if (doc["event"].is<String>()) {
|
||||
eventStr = doc["event"].as<String>();
|
||||
}
|
||||
|
||||
String data;
|
||||
if (doc["data"].is<const char*>()) {
|
||||
data = doc["data"].as<const char*>();
|
||||
} else if (doc["data"].is<JsonVariantConst>()) {
|
||||
// If data is a nested JSON object/array, serialize it back to string
|
||||
String tmp;
|
||||
serializeJson(doc["data"], tmp);
|
||||
data = tmp;
|
||||
}
|
||||
|
||||
if (eventStr.length() == 0 || data.length() == 0) {
|
||||
String dbg;
|
||||
serializeJson(doc, dbg);
|
||||
LOG_WARN("Cluster", String("CLUSTER_EVENT missing 'event' or 'data' | payload=") + dbg);
|
||||
return;
|
||||
}
|
||||
|
||||
std::string eventKey(eventStr.c_str());
|
||||
LOG_DEBUG("Cluster", String("Firing event '") + eventStr + "' with dataLen=" + String(data.length()));
|
||||
ctx.fire(eventKey, &data);
|
||||
}
|
||||
|
||||
void ClusterManager::onRawMessage(const char* msg) {
|
||||
const std::size_t prefixLen = strlen(ClusterProtocol::RAW_MSG);
|
||||
if (msg[prefixLen] != ':') {
|
||||
LOG_WARN("Cluster", "RAW message received without payload delimiter");
|
||||
return;
|
||||
}
|
||||
|
||||
const char* payloadStart = msg + prefixLen + 1;
|
||||
if (*payloadStart == '\0') {
|
||||
LOG_WARN("Cluster", "RAW message received with empty payload");
|
||||
return;
|
||||
}
|
||||
|
||||
String payload(payloadStart);
|
||||
ctx.fire("udp/raw", &payload);
|
||||
}
|
||||
|
||||
void ClusterManager::addOrUpdateNode(const String& nodeHost, IPAddress nodeIP) {
|
||||
@@ -71,12 +285,13 @@ void ClusterManager::addOrUpdateNode(const String& nodeHost, IPAddress nodeIP) {
|
||||
newNode.hostname = nodeHost;
|
||||
newNode.ip = nodeIP;
|
||||
newNode.lastSeen = millis();
|
||||
updateNodeStatus(newNode, newNode.lastSeen, ctx.config.node_inactive_threshold_ms, ctx.config.node_dead_threshold_ms);
|
||||
updateNodeStatus(newNode, newNode.lastSeen, ctx.config.node_inactive_threshold_ms, ctx.config.node_dead_threshold_ms);
|
||||
memberList[nodeHost] = newNode;
|
||||
LOG_INFO("Cluster", "Added node: " + nodeHost + " @ " + newNode.ip.toString() + " | Status: " + statusToStr(newNode.status) + " | last update: 0");
|
||||
//fetchNodeInfo(nodeIP); // Do not fetch here, handled by periodic task
|
||||
}
|
||||
|
||||
// unused http client to fetch complete node info
|
||||
void ClusterManager::fetchNodeInfo(const IPAddress& ip) {
|
||||
if(ip == ctx.localIP) {
|
||||
LOG_DEBUG("Cluster", "Skipping fetch for local node");
|
||||
@@ -192,33 +407,20 @@ void ClusterManager::heartbeatTaskCallback() {
|
||||
node.lastSeen = millis();
|
||||
node.status = NodeInfo::ACTIVE;
|
||||
updateLocalNodeResources();
|
||||
ctx.fire("node_discovered", &node);
|
||||
addOrUpdateNode(ctx.hostname, ctx.localIP);
|
||||
}
|
||||
|
||||
// Broadcast heartbeat so peers can respond with their node info
|
||||
lastHeartbeatSentAt = millis();
|
||||
ctx.udp->beginPacket("255.255.255.255", ctx.config.udp_port);
|
||||
String hb = String(ClusterProtocol::HEARTBEAT_MSG) + ":" + ctx.hostname;
|
||||
ctx.udp->write(hb.c_str());
|
||||
ctx.udp->endPacket();
|
||||
}
|
||||
|
||||
void ClusterManager::updateAllMembersInfoTaskCallback() {
|
||||
auto& memberList = *ctx.memberList;
|
||||
|
||||
// Limit concurrent HTTP requests to prevent memory pressure
|
||||
const size_t maxConcurrentRequests = ctx.config.max_concurrent_http_requests;
|
||||
size_t requestCount = 0;
|
||||
|
||||
for (auto& pair : memberList) {
|
||||
const NodeInfo& node = pair.second;
|
||||
if (node.ip != ctx.localIP) {
|
||||
// Only process a limited number of requests per cycle
|
||||
if (requestCount >= maxConcurrentRequests) {
|
||||
LOG_DEBUG("Cluster", "Limiting concurrent HTTP requests to prevent memory pressure");
|
||||
break;
|
||||
}
|
||||
|
||||
fetchNodeInfo(node.ip);
|
||||
requestCount++;
|
||||
|
||||
// Add small delay between requests to prevent overwhelming the system
|
||||
delay(100);
|
||||
}
|
||||
}
|
||||
// HTTP-based member info fetching disabled; node info is provided via UDP responses to heartbeats
|
||||
// No-op to reduce network and memory usage
|
||||
}
|
||||
|
||||
void ClusterManager::updateAllNodeStatuses() {
|
||||
|
||||
@@ -29,4 +29,11 @@ void NodeContext::fire(const std::string& event, void* data) {
|
||||
for (auto& cb : eventRegistry[event]) {
|
||||
cb(data);
|
||||
}
|
||||
for (auto& acb : anyEventSubscribers) {
|
||||
acb(event, data);
|
||||
}
|
||||
}
|
||||
|
||||
void NodeContext::onAny(AnyEventCallback cb) {
|
||||
anyEventSubscribers.push_back(cb);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,36 @@
|
||||
ClusterService::ClusterService(NodeContext& ctx) : ctx(ctx) {}
|
||||
|
||||
void ClusterService::registerEndpoints(ApiServer& api) {
|
||||
api.addEndpoint("/api/cluster/members", HTTP_GET,
|
||||
api.registerEndpoint("/api/cluster/members", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleMembersRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
// Generic cluster broadcast endpoint
|
||||
api.registerEndpoint("/api/cluster/event", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) {
|
||||
if (!request->hasParam("event", true) || !request->hasParam("payload", true)) {
|
||||
request->send(400, "application/json", "{\"error\":\"Missing 'event' or 'payload'\"}");
|
||||
return;
|
||||
}
|
||||
String eventName = request->getParam("event", true)->value();
|
||||
String payloadStr = request->getParam("payload", true)->value();
|
||||
JsonDocument envelope;
|
||||
envelope["event"] = eventName;
|
||||
envelope["data"] = payloadStr; // pass payload as JSON string
|
||||
String eventJson;
|
||||
serializeJson(envelope, eventJson);
|
||||
std::string ev = "cluster/broadcast";
|
||||
ctx.fire(ev, &eventJson);
|
||||
request->send(200, "application/json", "{\"ok\":true}");
|
||||
},
|
||||
std::vector<ParamSpec>{
|
||||
ParamSpec{String("event"), true, String("body"), String("string"), {}},
|
||||
ParamSpec{String("payload"), true, String("body"), String("string"), {}}
|
||||
});
|
||||
}
|
||||
|
||||
void ClusterService::registerTasks(TaskManager& taskManager) {
|
||||
// ClusterService doesn't register any tasks itself
|
||||
}
|
||||
|
||||
void ClusterService::handleMembersRequest(AsyncWebServerRequest* request) {
|
||||
@@ -21,11 +48,6 @@ void ClusterService::handleMembersRequest(AsyncWebServerRequest* request) {
|
||||
obj["lastSeen"] = node.lastSeen;
|
||||
obj["latency"] = node.latency;
|
||||
obj["status"] = statusToStr(node.status);
|
||||
obj["resources"]["freeHeap"] = node.resources.freeHeap;
|
||||
obj["resources"]["chipId"] = node.resources.chipId;
|
||||
obj["resources"]["sdkVersion"] = node.resources.sdkVersion;
|
||||
obj["resources"]["cpuFreqMHz"] = node.resources.cpuFreqMHz;
|
||||
obj["resources"]["flashChipSize"] = node.resources.flashChipSize;
|
||||
|
||||
// Add labels if present
|
||||
if (!node.labels.empty()) {
|
||||
|
||||
@@ -10,11 +10,15 @@ MonitoringService::MonitoringService(CpuUsage& cpuUsage)
|
||||
}
|
||||
|
||||
void MonitoringService::registerEndpoints(ApiServer& api) {
|
||||
api.addEndpoint("/api/monitoring/resources", HTTP_GET,
|
||||
api.registerEndpoint("/api/monitoring/resources", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleResourcesRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
}
|
||||
|
||||
void MonitoringService::registerTasks(TaskManager& taskManager) {
|
||||
// MonitoringService doesn't register any tasks itself
|
||||
}
|
||||
|
||||
MonitoringService::SystemResources MonitoringService::getSystemResources() const {
|
||||
SystemResources resources;
|
||||
|
||||
|
||||
@@ -6,20 +6,20 @@ NetworkService::NetworkService(NetworkManager& networkManager)
|
||||
|
||||
void NetworkService::registerEndpoints(ApiServer& api) {
|
||||
// WiFi scanning endpoints
|
||||
api.addEndpoint("/api/network/wifi/scan", HTTP_POST,
|
||||
api.registerEndpoint("/api/network/wifi/scan", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleWifiScanRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
api.addEndpoint("/api/network/wifi/scan", HTTP_GET,
|
||||
api.registerEndpoint("/api/network/wifi/scan", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleGetWifiNetworks(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
// Network status and configuration endpoints
|
||||
api.addEndpoint("/api/network/status", HTTP_GET,
|
||||
api.registerEndpoint("/api/network/status", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleNetworkStatus(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
api.addEndpoint("/api/network/wifi/config", HTTP_POST,
|
||||
api.registerEndpoint("/api/network/wifi/config", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleSetWifiConfig(request); },
|
||||
std::vector<ParamSpec>{
|
||||
ParamSpec{String("ssid"), true, String("body"), String("string"), {}, String("")},
|
||||
@@ -29,6 +29,10 @@ void NetworkService::registerEndpoints(ApiServer& api) {
|
||||
});
|
||||
}
|
||||
|
||||
void NetworkService::registerTasks(TaskManager& taskManager) {
|
||||
// NetworkService doesn't register any tasks itself
|
||||
}
|
||||
|
||||
void NetworkService::handleWifiScanRequest(AsyncWebServerRequest* request) {
|
||||
networkManager.scanWifi();
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@ NodeService::NodeService(NodeContext& ctx, ApiServer& apiServer) : ctx(ctx), api
|
||||
|
||||
void NodeService::registerEndpoints(ApiServer& api) {
|
||||
// Status endpoint
|
||||
api.addEndpoint("/api/node/status", HTTP_GET,
|
||||
api.registerEndpoint("/api/node/status", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
// Update endpoint with file upload
|
||||
api.addEndpoint("/api/node/update", HTTP_POST,
|
||||
api.registerEndpoint("/api/node/update", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleUpdateRequest(request); },
|
||||
[this](AsyncWebServerRequest* request, const String& filename, size_t index, uint8_t* data, size_t len, bool final) {
|
||||
handleUpdateUpload(request, filename, index, data, len, final);
|
||||
@@ -21,14 +21,36 @@ void NodeService::registerEndpoints(ApiServer& api) {
|
||||
});
|
||||
|
||||
// Restart endpoint
|
||||
api.addEndpoint("/api/node/restart", HTTP_POST,
|
||||
api.registerEndpoint("/api/node/restart", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleRestartRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
// Endpoints endpoint
|
||||
api.addEndpoint("/api/node/endpoints", HTTP_GET,
|
||||
api.registerEndpoint("/api/node/endpoints", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleEndpointsRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
// Generic local event endpoint
|
||||
api.registerEndpoint("/api/node/event", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) {
|
||||
if (!request->hasParam("event", true) || !request->hasParam("payload", true)) {
|
||||
request->send(400, "application/json", "{\"error\":\"Missing 'event' or 'payload'\"}");
|
||||
return;
|
||||
}
|
||||
String eventName = request->getParam("event", true)->value();
|
||||
String payloadStr = request->getParam("payload", true)->value();
|
||||
std::string ev = eventName.c_str();
|
||||
ctx.fire(ev, &payloadStr);
|
||||
request->send(200, "application/json", "{\"ok\":true}");
|
||||
},
|
||||
std::vector<ParamSpec>{
|
||||
ParamSpec{String("event"), true, String("body"), String("string"), {}},
|
||||
ParamSpec{String("payload"), true, String("body"), String("string"), {}}
|
||||
});
|
||||
}
|
||||
|
||||
void NodeService::registerTasks(TaskManager& taskManager) {
|
||||
// NodeService doesn't register any tasks itself
|
||||
}
|
||||
|
||||
void NodeService::handleStatusRequest(AsyncWebServerRequest* request) {
|
||||
|
||||
@@ -20,3 +20,7 @@ void StaticFileService::registerEndpoints(ApiServer& api) {
|
||||
api.serveStatic("/", LittleFS, "/public", "max-age=3600");
|
||||
}
|
||||
|
||||
void StaticFileService::registerTasks(TaskManager& taskManager) {
|
||||
// StaticFileService doesn't register any tasks itself
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
TaskService::TaskService(TaskManager& taskManager) : taskManager(taskManager) {}
|
||||
|
||||
void TaskService::registerEndpoints(ApiServer& api) {
|
||||
api.addEndpoint("/api/tasks/status", HTTP_GET,
|
||||
api.registerEndpoint("/api/tasks/status", HTTP_GET,
|
||||
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
|
||||
std::vector<ParamSpec>{});
|
||||
|
||||
api.addEndpoint("/api/tasks/control", HTTP_POST,
|
||||
api.registerEndpoint("/api/tasks/control", HTTP_POST,
|
||||
[this](AsyncWebServerRequest* request) { handleControlRequest(request); },
|
||||
std::vector<ParamSpec>{
|
||||
ParamSpec{
|
||||
@@ -31,6 +31,10 @@ void TaskService::registerEndpoints(ApiServer& api) {
|
||||
});
|
||||
}
|
||||
|
||||
void TaskService::registerTasks(TaskManager& taskManager) {
|
||||
// TaskService doesn't register any tasks itself - it manages other tasks
|
||||
}
|
||||
|
||||
void TaskService::handleStatusRequest(AsyncWebServerRequest* request) {
|
||||
JsonDocument scratch;
|
||||
auto taskStatuses = taskManager.getAllTaskStatuses(scratch);
|
||||
|
||||
@@ -10,10 +10,11 @@ Config::Config() {
|
||||
api_server_port = 80;
|
||||
|
||||
// Cluster Configuration
|
||||
discovery_interval_ms = 1000;
|
||||
heartbeat_interval_ms = 2000;
|
||||
discovery_interval_ms = 1000; // TODO retire this in favor of heartbeat_interval_ms
|
||||
cluster_listen_interval_ms = 10;
|
||||
heartbeat_interval_ms = 5000;
|
||||
status_update_interval_ms = 1000;
|
||||
member_info_update_interval_ms = 10000;
|
||||
member_info_update_interval_ms = 10000; // TODO retire this in favor of heartbeat_interval_ms
|
||||
print_interval_ms = 5000;
|
||||
|
||||
// Node Status Thresholds
|
||||
|
||||
1
test/.gitignore
vendored
Normal file
1
test/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
69
test/README
69
test/README
@@ -1,11 +1,64 @@
|
||||
# Test Scripts
|
||||
|
||||
This directory is intended for PlatformIO Test Runner and project tests.
|
||||
This directory contains JavaScript test scripts to interact with the Spore device, primarily for testing cluster event broadcasting.
|
||||
|
||||
Unit Testing is a software testing method by which individual units of
|
||||
source code, sets of one or more MCU program modules together with associated
|
||||
control data, usage procedures, and operating procedures, are tested to
|
||||
determine whether they are fit for use. Unit testing finds problems early
|
||||
in the development cycle.
|
||||
## Prerequisites
|
||||
|
||||
These scripts require [Node.js](https://nodejs.org/) to be installed on your system.
|
||||
|
||||
## How to Run
|
||||
|
||||
### 1. HTTP Cluster Broadcast Color (`test/http-cluster-broadcast-color.js`)
|
||||
|
||||
This script sends HTTP POST requests to the `/api/cluster/event` endpoint on your Spore device. It broadcasts NeoPattern color changes across the cluster every 5 seconds.
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
node test/http-cluster-broadcast-color.js <device-ip>
|
||||
```
|
||||
Example:
|
||||
```
|
||||
node test/http-cluster-broadcast-color.js 10.0.1.53
|
||||
```
|
||||
This will broadcast `{ event: "api/neopattern/color", data: { color: "#RRGGBB", brightness: 128 } }` every 5 seconds to the cluster via `/api/cluster/event`.
|
||||
|
||||
### 2. WS Local Color Setter (`test/ws-color-client.js`)
|
||||
|
||||
Connects to the device WebSocket (`/ws`) and sets a solid color locally (non-broadcast) every 5 seconds by firing `api/neopattern/color`.
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
node test/ws-color-client.js ws://<device-ip>/ws
|
||||
```
|
||||
Example:
|
||||
```
|
||||
node test/ws-color-client.js ws://10.0.1.53/ws
|
||||
```
|
||||
|
||||
### 3. WS Cluster Broadcast Color (`test/ws-cluster-broadcast-color.js`)
|
||||
|
||||
Connects to the device WebSocket (`/ws`) and broadcasts a color change to all peers every 5 seconds by firing `cluster/broadcast` with the proper envelope.
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
node test/ws-cluster-broadcast-color.js ws://<device-ip>/ws
|
||||
```
|
||||
Example:
|
||||
```
|
||||
node test/ws-cluster-broadcast-color.js ws://10.0.1.53/ws
|
||||
```
|
||||
|
||||
### 4. WS Cluster Broadcast Rainbow (`test/ws-cluster-broadcast-rainbow.js`)
|
||||
|
||||
Broadcasts a smooth rainbow color transition over WebSocket using `cluster/broadcast` and the `api/neopattern/color` event. Update rate defaults to `UPDATE_RATE` in the script (e.g., 100 ms).
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
node test/ws-cluster-broadcast-rainbow.js ws://<device-ip>/ws
|
||||
```
|
||||
Example:
|
||||
```
|
||||
node test/ws-cluster-broadcast-rainbow.js ws://10.0.1.53/ws
|
||||
```
|
||||
Note: Very fast update intervals (e.g., 10 ms) may saturate links or the device.
|
||||
|
||||
More information about PlatformIO Unit Testing:
|
||||
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html
|
||||
|
||||
52
test/neopattern/http-cluster-broadcast-color.js
Normal file
52
test/neopattern/http-cluster-broadcast-color.js
Normal file
@@ -0,0 +1,52 @@
|
||||
// Simple HTTP client to broadcast a neopattern color change to the cluster
|
||||
// Usage: node cluster-broadcast-color.js 10.0.1.53
|
||||
|
||||
const http = require('http');
|
||||
|
||||
const host = process.argv[2] || '127.0.0.1';
|
||||
const port = 80;
|
||||
|
||||
const colors = ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF'];
|
||||
let idx = 0;
|
||||
|
||||
function postClusterEvent(event, payloadObj) {
|
||||
const payload = encodeURIComponent(JSON.stringify(payloadObj));
|
||||
const body = `event=${encodeURIComponent(event)}&payload=${payload}`;
|
||||
|
||||
const options = {
|
||||
host,
|
||||
port,
|
||||
path: '/api/cluster/event',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': Buffer.byteLength(body)
|
||||
}
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => {
|
||||
console.log('Response:', res.statusCode, data);
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
console.error('Request error:', err.message);
|
||||
});
|
||||
|
||||
req.write(body);
|
||||
req.end();
|
||||
}
|
||||
|
||||
console.log(`Broadcasting color changes to http://${host}/api/cluster/event ...`);
|
||||
setInterval(() => {
|
||||
const color = colors[idx % colors.length];
|
||||
idx++;
|
||||
const payload = { color, brightness: 80 };
|
||||
console.log('Broadcasting color:', payload);
|
||||
postClusterEvent('api/neopattern/color', payload);
|
||||
}, 5000);
|
||||
|
||||
|
||||
46
test/neopattern/ws-cluster-broadcast-color.js
Normal file
46
test/neopattern/ws-cluster-broadcast-color.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// WebSocket client to broadcast neopattern color changes across the cluster
|
||||
// Usage: node ws-cluster-broadcast-color.js ws://<device-ip>/ws
|
||||
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const url = process.argv[2] || 'ws://127.0.0.1/ws';
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
const colors = ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF'];
|
||||
let idx = 0;
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log('Connected to', url);
|
||||
// Broadcast color change every 5 seconds via cluster/broadcast
|
||||
setInterval(() => {
|
||||
const color = colors[idx % colors.length];
|
||||
idx++;
|
||||
const payload = { color, brightness: 80 };
|
||||
const envelope = {
|
||||
event: 'api/neopattern/color',
|
||||
data: payload // server will serialize object payloads
|
||||
};
|
||||
const msg = { event: 'cluster/broadcast', payload: envelope };
|
||||
ws.send(JSON.stringify(msg));
|
||||
console.log('Broadcasted color event', payload);
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
console.log('Received:', msg);
|
||||
} catch (e) {
|
||||
console.log('Received raw:', data.toString());
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('WebSocket error:', err.message);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('WebSocket closed');
|
||||
});
|
||||
|
||||
|
||||
71
test/neopattern/ws-cluster-broadcast-rainbow.js
Normal file
71
test/neopattern/ws-cluster-broadcast-rainbow.js
Normal file
@@ -0,0 +1,71 @@
|
||||
// WebSocket client to broadcast smooth rainbow color changes across the cluster
|
||||
// Usage: node ws-cluster-broadcast-rainbow.js ws://<device-ip>/ws
|
||||
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const url = process.argv[2] || 'ws://127.0.0.1/ws';
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
function hsvToRgb(h, s, v) {
|
||||
const c = v * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = v - c;
|
||||
let r = 0, g = 0, b = 0;
|
||||
if (h < 60) { r = c; g = x; b = 0; }
|
||||
else if (h < 120) { r = x; g = c; b = 0; }
|
||||
else if (h < 180) { r = 0; g = c; b = x; }
|
||||
else if (h < 240) { r = 0; g = x; b = c; }
|
||||
else if (h < 300) { r = x; g = 0; b = c; }
|
||||
else { r = c; g = 0; b = x; }
|
||||
const R = Math.round((r + m) * 255);
|
||||
const G = Math.round((g + m) * 255);
|
||||
const B = Math.round((b + m) * 255);
|
||||
return { r: R, g: G, b: B };
|
||||
}
|
||||
|
||||
function toHex({ r, g, b }) {
|
||||
const h = (n) => n.toString(16).padStart(2, '0').toUpperCase();
|
||||
return `#${h(r)}${h(g)}${h(b)}`;
|
||||
}
|
||||
|
||||
let hue = 0;
|
||||
const SAT = 1.0; // full saturation
|
||||
const VAL = 1.0; // full value
|
||||
const BRIGHTNESS = 80;
|
||||
const UPDATE_RATE = 100; // ms
|
||||
|
||||
let timer = null;
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log('Connected to', url);
|
||||
// UPDATE_RATE ms updates (10 Hz). Be aware this can saturate slow links.
|
||||
timer = setInterval(() => {
|
||||
const rgb = hsvToRgb(hue, SAT, VAL);
|
||||
const color = toHex(rgb);
|
||||
const envelope = {
|
||||
event: 'api/neopattern/color',
|
||||
data: { color, brightness: BRIGHTNESS }
|
||||
};
|
||||
const msg = { event: 'cluster/broadcast', payload: envelope };
|
||||
try {
|
||||
ws.send(JSON.stringify(msg));
|
||||
} catch (_) {}
|
||||
hue = (hue + 2) % 360; // advance hue (adjust for speed)
|
||||
}, UPDATE_RATE);
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
// Optionally throttle logs: comment out for quieter output
|
||||
// console.log('WS:', data.toString());
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('WebSocket error:', err.message);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
if (timer) clearInterval(timer);
|
||||
console.log('WebSocket closed');
|
||||
});
|
||||
|
||||
|
||||
48
test/neopattern/ws-color-client.js
Normal file
48
test/neopattern/ws-color-client.js
Normal file
@@ -0,0 +1,48 @@
|
||||
// Simple WebSocket client to test streaming API color changes
|
||||
// Usage: node ws-color-client.js ws://<device-ip>/ws
|
||||
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const url = process.argv[2] || 'ws://127.0.0.1/ws';
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
const colors = [
|
||||
'#FF0000', // red
|
||||
'#00FF00', // green
|
||||
'#0000FF', // blue
|
||||
'#FFFF00', // yellow
|
||||
'#FF00FF', // magenta
|
||||
'#00FFFF' // cyan
|
||||
];
|
||||
let idx = 0;
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log('Connected to', url);
|
||||
// Send a message every 5 seconds to set solid color
|
||||
setInterval(() => {
|
||||
const color = colors[idx % colors.length];
|
||||
idx++;
|
||||
const payload = { color, brightness: 80 };
|
||||
// Send payload as an object (server supports string or object)
|
||||
const msg = { event: 'api/neopattern/color', payload };
|
||||
ws.send(JSON.stringify(msg));
|
||||
console.log('Sent color event', payload);
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
console.log('Received:', msg);
|
||||
} catch (e) {
|
||||
console.log('Received raw:', data.toString());
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('WebSocket error:', err.message);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('WebSocket closed');
|
||||
});
|
||||
33
test/package-lock.json
generated
Normal file
33
test/package-lock.json
generated
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "test",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
test/package.json
Normal file
21
test/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"scripts": {
|
||||
"pixelstream:fade-green-blue": "node pixelstream/fade-green-blue.js",
|
||||
"pixelstream:bouncing-ball": "node pixelstream/bouncing-ball.js",
|
||||
"pixelstream:rainbow": "node pixelstream/rainbow.js",
|
||||
"pixelstream:lava-lamp": "node pixelstream/lava-lamp.js",
|
||||
"pixelstream:meteor-rain": "node pixelstream/meteor-rain.js",
|
||||
"pixelstream:spiral-bloom": "node pixelstream/spiral-bloom.js",
|
||||
"pixelstream:ocean-glimmer": "node pixelstream/ocean-glimmer.js",
|
||||
"pixelstream:nebula-drift": "node pixelstream/nebula-drift.js",
|
||||
"pixelstream:voxel-fireflies": "node pixelstream/voxel-fireflies.js",
|
||||
"pixelstream:wormhole-tunnel": "node pixelstream/wormhole-tunnel.js",
|
||||
"pixelstream:circuit-pulse": "node pixelstream/circuit-pulse.js",
|
||||
"pixelstream:aurora-curtains": "node pixelstream/aurora-curtains.js",
|
||||
"pixelstream:snek": "npm start --prefix pixelstream/snek",
|
||||
"pixelstream:tetris": "npm start --prefix pixelstream/tetris"
|
||||
}
|
||||
}
|
||||
115
test/pixelstream/aurora-curtains.js
Normal file
115
test/pixelstream/aurora-curtains.js
Normal file
@@ -0,0 +1,115 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
clamp,
|
||||
createFrame,
|
||||
frameToPayload,
|
||||
hexToRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 65;
|
||||
const BAND_COUNT = 5;
|
||||
const WAVE_SPEED = 0.35;
|
||||
const HORIZONTAL_SWAY = 0.45;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('01010a') },
|
||||
{ stop: 0.2, color: hexToRgb('041332') },
|
||||
{ stop: 0.4, color: hexToRgb('0c3857') },
|
||||
{ stop: 0.65, color: hexToRgb('1aa07a') },
|
||||
{ stop: 0.85, color: hexToRgb('68d284') },
|
||||
{ stop: 1.0, color: hexToRgb('f4f5c6') },
|
||||
];
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node aurora-curtains.js <device-ip> [port] [width] [height] [interval-ms]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
const frame = createFrame(width, height);
|
||||
const bands = createBands(BAND_COUNT, width);
|
||||
let timeSeconds = 0;
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function createBands(count, matrixWidth) {
|
||||
const generatedBands = [];
|
||||
for (let index = 0; index < count; ++index) {
|
||||
generatedBands.push({
|
||||
center: Math.random() * (matrixWidth - 1),
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
width: 1.2 + Math.random() * 1.8,
|
||||
});
|
||||
}
|
||||
return generatedBands;
|
||||
}
|
||||
|
||||
function generateFrame() {
|
||||
timeSeconds += frameTimeSeconds;
|
||||
|
||||
for (let row = 0; row < height; ++row) {
|
||||
const verticalRatio = row / Math.max(1, height - 1);
|
||||
for (let col = 0; col < width; ++col) {
|
||||
let intensity = 0;
|
||||
|
||||
bands.forEach((band, index) => {
|
||||
const sway = Math.sin(timeSeconds * WAVE_SPEED + band.phase + verticalRatio * Math.PI * 2) * HORIZONTAL_SWAY;
|
||||
const center = band.center + sway * (index % 2 === 0 ? 1 : -1);
|
||||
const distance = Math.abs(col - center);
|
||||
const blurred = Math.exp(-(distance * distance) / (2 * band.width * band.width));
|
||||
intensity += blurred * (0.8 + Math.sin(timeSeconds * 0.4 + index) * 0.2);
|
||||
});
|
||||
|
||||
const normalized = clamp(intensity / bands.length, 0, 1);
|
||||
const gradientBlend = clamp((normalized * 0.7 + verticalRatio * 0.3), 0, 1);
|
||||
frame[toIndex(col, row, width)] = samplePalette(paletteStops, gradientBlend);
|
||||
}
|
||||
}
|
||||
|
||||
return frameToPayload(frame);
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming aurora curtains to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms)`
|
||||
);
|
||||
|
||||
80
test/pixelstream/bouncing-ball.js
Normal file
80
test/pixelstream/bouncing-ball.js
Normal file
@@ -0,0 +1,80 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || '4210', 10);
|
||||
const pixels = parseInt(process.argv[4] || '64', 10);
|
||||
const intervalMs = parseInt(process.argv[5] || '30', 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node bouncing-ball.js <device-ip> [port] [pixels] [interval-ms]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
|
||||
let position = Math.random() * (pixels - 1);
|
||||
let velocity = randomVelocity();
|
||||
|
||||
function randomVelocity() {
|
||||
const min = 0.15;
|
||||
const max = 0.4;
|
||||
const sign = Math.random() < 0.5 ? -1 : 1;
|
||||
return (min + Math.random() * (max - min)) * sign;
|
||||
}
|
||||
|
||||
function rebound(sign) {
|
||||
velocity = randomVelocity() * sign;
|
||||
}
|
||||
|
||||
function mix(a, b, t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
function generateFrame() {
|
||||
const dt = intervalMs / 1000;
|
||||
position += velocity * dt * 60; // scale velocity to 60 FPS reference
|
||||
|
||||
if (position < 0) {
|
||||
position = -position;
|
||||
rebound(1);
|
||||
} else if (position > pixels - 1) {
|
||||
position = (pixels - 1) - (position - (pixels - 1));
|
||||
rebound(-1);
|
||||
}
|
||||
|
||||
const activeIndex = Math.max(0, Math.min(pixels - 1, Math.round(position)));
|
||||
|
||||
let payload = 'RAW:';
|
||||
for (let i = 0; i < pixels; i++) {
|
||||
if (i === activeIndex) {
|
||||
payload += 'ff8000';
|
||||
continue;
|
||||
}
|
||||
|
||||
const distance = Math.abs(i - position);
|
||||
const intensity = Math.max(0, 1 - distance);
|
||||
const green = Math.round(mix(20, 200, intensity)).toString(16).padStart(2, '0');
|
||||
const blue = Math.round(mix(40, 255, intensity)).toString(16).padStart(2, '0');
|
||||
payload += '00' + green + blue;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Streaming bouncing ball pattern to ${host}:${port} with ${pixels} pixels (interval=${intervalMs}ms)`);
|
||||
|
||||
166
test/pixelstream/circuit-pulse.js
Normal file
166
test/pixelstream/circuit-pulse.js
Normal file
@@ -0,0 +1,166 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
addHexColor,
|
||||
createFrame,
|
||||
fadeFrame,
|
||||
frameToPayload,
|
||||
hexToRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 50;
|
||||
const PATH_FADE = 0.85;
|
||||
const PULSE_LENGTH = 6;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('020209') },
|
||||
{ stop: 0.3, color: hexToRgb('023047') },
|
||||
{ stop: 0.6, color: hexToRgb('115173') },
|
||||
{ stop: 0.8, color: hexToRgb('1ca78f') },
|
||||
{ stop: 1.0, color: hexToRgb('94fdf3') },
|
||||
];
|
||||
|
||||
const accentColors = ['14f5ff', 'a7ff4d', 'ffcc3f'];
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node circuit-pulse.js <device-ip> [port] [width] [height] [interval-ms]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
const frame = createFrame(width, height);
|
||||
const paths = createPaths(width, height);
|
||||
const pulses = createPulses(paths.length);
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function createPaths(matrixWidth, matrixHeight) {
|
||||
const horizontalStep = Math.max(2, Math.floor(matrixHeight / 4));
|
||||
const verticalStep = Math.max(2, Math.floor(matrixWidth / 4));
|
||||
const generatedPaths = [];
|
||||
|
||||
for (let y = 1; y < matrixHeight; y += horizontalStep) {
|
||||
const path = [];
|
||||
for (let x = 0; x < matrixWidth; ++x) {
|
||||
path.push({ x, y });
|
||||
}
|
||||
generatedPaths.push(path);
|
||||
}
|
||||
|
||||
for (let x = 2; x < matrixWidth; x += verticalStep) {
|
||||
const path = [];
|
||||
for (let y = 0; y < matrixHeight; ++y) {
|
||||
path.push({ x, y });
|
||||
}
|
||||
generatedPaths.push(path);
|
||||
}
|
||||
|
||||
return generatedPaths;
|
||||
}
|
||||
|
||||
function createPulses(count) {
|
||||
const pulseList = [];
|
||||
for (let index = 0; index < count; ++index) {
|
||||
pulseList.push(spawnPulse(index));
|
||||
}
|
||||
return pulseList;
|
||||
}
|
||||
|
||||
function spawnPulse(pathIndex) {
|
||||
const color = accentColors[pathIndex % accentColors.length];
|
||||
return {
|
||||
pathIndex,
|
||||
position: 0,
|
||||
speed: 3 + Math.random() * 2,
|
||||
color,
|
||||
};
|
||||
}
|
||||
|
||||
function updatePulse(pulse, deltaSeconds) {
|
||||
pulse.position += pulse.speed * deltaSeconds;
|
||||
const path = paths[pulse.pathIndex];
|
||||
|
||||
if (!path || path.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pulse.position >= path.length + PULSE_LENGTH) {
|
||||
Object.assign(pulse, spawnPulse(pulse.pathIndex));
|
||||
pulse.position = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPulse(pulse) {
|
||||
const path = paths[pulse.pathIndex];
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let offset = 0; offset < PULSE_LENGTH; ++offset) {
|
||||
const index = Math.floor(pulse.position) - offset;
|
||||
if (index < 0 || index >= path.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { x, y } = path[index];
|
||||
const intensity = Math.max(0, 1 - offset / PULSE_LENGTH);
|
||||
const baseColor = samplePalette(paletteStops, intensity);
|
||||
frame[toIndex(x, y, width)] = baseColor;
|
||||
addHexColor(frame, toIndex(x, y, width), pulse.color, intensity * 1.4);
|
||||
}
|
||||
}
|
||||
|
||||
function generateFrame() {
|
||||
fadeFrame(frame, PATH_FADE);
|
||||
|
||||
pulses.forEach((pulse) => {
|
||||
updatePulse(pulse, frameTimeSeconds);
|
||||
renderPulse(pulse);
|
||||
});
|
||||
|
||||
return frameToPayload(frame);
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming circuit pulse to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms, paths=${paths.length})`
|
||||
);
|
||||
|
||||
55
test/pixelstream/fade-green-blue.js
Normal file
55
test/pixelstream/fade-green-blue.js
Normal file
@@ -0,0 +1,55 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || '4210', 10);
|
||||
const pixels = parseInt(process.argv[4] || '64', 10);
|
||||
const speed = parseFloat(process.argv[5] || '0.5'); // cycles per second
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node fade-green-blue.js <device-ip> [port] [pixels] [speed-hz]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const intervalMs = 50;
|
||||
let tick = 0;
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
|
||||
function generateFrame() {
|
||||
const timeSeconds = (tick * intervalMs) / 1000;
|
||||
const phase = timeSeconds * speed * Math.PI * 2;
|
||||
const blend = (Math.sin(phase) + 1) * 0.5; // 0..1
|
||||
|
||||
const green = Math.round(255 * (1 - blend));
|
||||
const blue = Math.round(255 * blend);
|
||||
|
||||
let payload = 'RAW:';
|
||||
const gHex = green.toString(16).padStart(2, '0');
|
||||
const bHex = blue.toString(16).padStart(2, '0');
|
||||
|
||||
for (let i = 0; i < pixels; i++) {
|
||||
payload += '00';
|
||||
payload += gHex;
|
||||
payload += bHex;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
tick += 1;
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Streaming green/blue fade to ${host}:${port} with ${pixels} pixels (speed=${speed}Hz)`);
|
||||
|
||||
171
test/pixelstream/lava-lamp.js
Normal file
171
test/pixelstream/lava-lamp.js
Normal file
@@ -0,0 +1,171 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
clamp,
|
||||
hexToRgb,
|
||||
samplePalette: samplePaletteFromStops,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 60;
|
||||
const DEFAULT_BLOB_COUNT = 6;
|
||||
const BASE_BLOB_SPEED = 0.18;
|
||||
const PHASE_SPEED_MIN = 0.6;
|
||||
const PHASE_SPEED_MAX = 1.2;
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
const blobCount = parseInt(process.argv[7] || String(DEFAULT_BLOB_COUNT), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node lava-lamp.js <device-ip> [port] [width] [height] [interval-ms] [blob-count]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs) || Number.isNaN(blobCount)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, interval-ms, and blob-count.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (blobCount <= 0) {
|
||||
console.error('Blob count must be a positive integer.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const totalPixels = width * height;
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
|
||||
const maxAxis = Math.max(width, height);
|
||||
const minBlobRadius = Math.max(3, maxAxis * 0.18);
|
||||
const maxBlobRadius = Math.max(minBlobRadius + 1, maxAxis * 0.38);
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('050319') },
|
||||
{ stop: 0.28, color: hexToRgb('2a0c4f') },
|
||||
{ stop: 0.55, color: hexToRgb('8f1f73') },
|
||||
{ stop: 0.75, color: hexToRgb('ff4a22') },
|
||||
{ stop: 0.9, color: hexToRgb('ff9333') },
|
||||
{ stop: 1.0, color: hexToRgb('fff7b0') },
|
||||
];
|
||||
|
||||
const blobs = createBlobs(blobCount);
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function createBlobs(count) {
|
||||
const blobList = [];
|
||||
for (let index = 0; index < count; ++index) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const speed = BASE_BLOB_SPEED * (0.6 + Math.random() * 0.8);
|
||||
blobList.push({
|
||||
x: Math.random() * Math.max(1, width - 1),
|
||||
y: Math.random() * Math.max(1, height - 1),
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
minRadius: minBlobRadius * (0.6 + Math.random() * 0.3),
|
||||
maxRadius: maxBlobRadius * (0.8 + Math.random() * 0.4),
|
||||
intensity: 0.8 + Math.random() * 0.7,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
phaseVelocity: PHASE_SPEED_MIN + Math.random() * (PHASE_SPEED_MAX - PHASE_SPEED_MIN),
|
||||
});
|
||||
}
|
||||
return blobList;
|
||||
}
|
||||
|
||||
function updateBlobs(deltaSeconds) {
|
||||
const maxX = Math.max(0, width - 1);
|
||||
const maxY = Math.max(0, height - 1);
|
||||
|
||||
blobs.forEach((blob) => {
|
||||
blob.x += blob.vx * deltaSeconds;
|
||||
blob.y += blob.vy * deltaSeconds;
|
||||
|
||||
if (blob.x < 0) {
|
||||
blob.x = -blob.x;
|
||||
blob.vx = Math.abs(blob.vx);
|
||||
} else if (blob.x > maxX) {
|
||||
blob.x = 2 * maxX - blob.x;
|
||||
blob.vx = -Math.abs(blob.vx);
|
||||
}
|
||||
|
||||
if (blob.y < 0) {
|
||||
blob.y = -blob.y;
|
||||
blob.vy = Math.abs(blob.vy);
|
||||
} else if (blob.y > maxY) {
|
||||
blob.y = 2 * maxY - blob.y;
|
||||
blob.vy = -Math.abs(blob.vy);
|
||||
}
|
||||
|
||||
blob.phase += blob.phaseVelocity * deltaSeconds;
|
||||
});
|
||||
}
|
||||
|
||||
function generateFrame() {
|
||||
updateBlobs(frameTimeSeconds);
|
||||
|
||||
const frame = new Array(totalPixels);
|
||||
|
||||
for (let row = 0; row < height; ++row) {
|
||||
for (let col = 0; col < width; ++col) {
|
||||
const energy = calculateEnergyAt(col, row);
|
||||
const color = samplePaletteFromStops(paletteStops, energy);
|
||||
frame[toIndex(col, row, width)] = color;
|
||||
}
|
||||
}
|
||||
|
||||
return 'RAW:' + frame.join('');
|
||||
}
|
||||
|
||||
function calculateEnergyAt(col, row) {
|
||||
let energy = 0;
|
||||
|
||||
blobs.forEach((blob) => {
|
||||
const radius = getBlobRadius(blob);
|
||||
const dx = col - blob.x;
|
||||
const dy = row - blob.y;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
const falloff = Math.max(0, 1 - distance / radius);
|
||||
energy += blob.intensity * falloff * falloff;
|
||||
});
|
||||
|
||||
return clamp(energy / blobs.length, 0, 1);
|
||||
}
|
||||
|
||||
function getBlobRadius(blob) {
|
||||
const oscillation = (Math.sin(blob.phase) + 1) * 0.5;
|
||||
return blob.minRadius + (blob.maxRadius - blob.minRadius) * oscillation;
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming lava lamp to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms, blobs=${blobCount})`,
|
||||
);
|
||||
|
||||
132
test/pixelstream/meteor-rain.js
Normal file
132
test/pixelstream/meteor-rain.js
Normal file
@@ -0,0 +1,132 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
clamp,
|
||||
createFrame,
|
||||
fadeFrame,
|
||||
frameToPayload,
|
||||
hexToRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 45;
|
||||
const DEFAULT_METEOR_COUNT = 12;
|
||||
const BASE_SPEED_MIN = 4;
|
||||
const BASE_SPEED_MAX = 10;
|
||||
const TRAIL_DECAY = 0.76;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('0a0126') },
|
||||
{ stop: 0.3, color: hexToRgb('123d8b') },
|
||||
{ stop: 0.7, color: hexToRgb('21c7d9') },
|
||||
{ stop: 1.0, color: hexToRgb('f7ffff') },
|
||||
];
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
const meteorCount = parseInt(process.argv[7] || String(DEFAULT_METEOR_COUNT), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node meteor-rain.js <device-ip> [port] [width] [height] [interval-ms] [meteor-count]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs) || Number.isNaN(meteorCount)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, interval-ms, and meteor-count.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (meteorCount <= 0) {
|
||||
console.error('Meteor count must be a positive integer.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
const frame = createFrame(width, height);
|
||||
const meteors = createMeteors(meteorCount, width, height);
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function createMeteors(count, matrixWidth, matrixHeight) {
|
||||
const meteorList = [];
|
||||
for (let index = 0; index < count; ++index) {
|
||||
meteorList.push(spawnMeteor(matrixWidth, matrixHeight));
|
||||
}
|
||||
return meteorList;
|
||||
}
|
||||
|
||||
function spawnMeteor(matrixWidth, matrixHeight) {
|
||||
const angle = (Math.PI / 4) * (0.6 + Math.random() * 0.8);
|
||||
const speed = BASE_SPEED_MIN + Math.random() * (BASE_SPEED_MAX - BASE_SPEED_MIN);
|
||||
return {
|
||||
x: Math.random() * matrixWidth,
|
||||
y: -Math.random() * matrixHeight,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
};
|
||||
}
|
||||
|
||||
function drawMeteor(meteor) {
|
||||
const col = Math.round(meteor.x);
|
||||
const row = Math.round(meteor.y);
|
||||
if (col < 0 || col >= width || row < 0 || row >= height) {
|
||||
return;
|
||||
}
|
||||
|
||||
const energy = clamp(1.2 - Math.random() * 0.2, 0, 1);
|
||||
frame[toIndex(col, row, width)] = samplePalette(paletteStops, energy);
|
||||
}
|
||||
|
||||
function updateMeteors(deltaSeconds) {
|
||||
meteors.forEach((meteor, index) => {
|
||||
meteor.x += meteor.vx * deltaSeconds;
|
||||
meteor.y += meteor.vy * deltaSeconds;
|
||||
|
||||
drawMeteor(meteor);
|
||||
|
||||
if (meteor.x > width + 1 || meteor.y > height + 1) {
|
||||
meteors[index] = spawnMeteor(width, height);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function generateFrame() {
|
||||
fadeFrame(frame, TRAIL_DECAY);
|
||||
updateMeteors(frameTimeSeconds);
|
||||
return frameToPayload(frame);
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming meteor rain to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms, meteors=${meteorCount})`,
|
||||
);
|
||||
|
||||
104
test/pixelstream/nebula-drift.js
Normal file
104
test/pixelstream/nebula-drift.js
Normal file
@@ -0,0 +1,104 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
clamp,
|
||||
createFrame,
|
||||
fadeFrame,
|
||||
frameToPayload,
|
||||
hexToRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 70;
|
||||
const PRIMARY_SPEED = 0.15;
|
||||
const SECONDARY_SPEED = 0.32;
|
||||
const WAVE_SCALE = 0.75;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('100406') },
|
||||
{ stop: 0.25, color: hexToRgb('2e0f1f') },
|
||||
{ stop: 0.5, color: hexToRgb('6a1731') },
|
||||
{ stop: 0.7, color: hexToRgb('b63b32') },
|
||||
{ stop: 0.85, color: hexToRgb('f48b2a') },
|
||||
{ stop: 1.0, color: hexToRgb('ffe9b0') },
|
||||
];
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node nebula-drift.js <device-ip> [port] [width] [height] [interval-ms]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
const frame = createFrame(width, height);
|
||||
let timeSeconds = 0;
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function layeredWave(u, v, speed, offset) {
|
||||
return Math.sin((u * 3 + v * 2) * Math.PI * WAVE_SCALE + timeSeconds * speed + offset);
|
||||
}
|
||||
|
||||
function generateFrame() {
|
||||
timeSeconds += frameTimeSeconds;
|
||||
|
||||
for (let row = 0; row < height; ++row) {
|
||||
const v = row / Math.max(1, height - 1);
|
||||
for (let col = 0; col < width; ++col) {
|
||||
const u = col / Math.max(1, width - 1);
|
||||
const primary = layeredWave(u, v, PRIMARY_SPEED, 0);
|
||||
const secondary = layeredWave(v, u, SECONDARY_SPEED, Math.PI / 4);
|
||||
const tertiary = Math.sin((u + v) * Math.PI * 1.5 + timeSeconds * 0.18);
|
||||
|
||||
const combined = 0.45 * primary + 0.35 * secondary + 0.2 * tertiary;
|
||||
const envelope = Math.sin((u * v) * Math.PI * 2 + timeSeconds * 0.1) * 0.25 + 0.75;
|
||||
const value = clamp((combined * 0.5 + 0.5) * envelope, 0, 1);
|
||||
|
||||
frame[toIndex(col, row, width)] = samplePalette(paletteStops, value);
|
||||
}
|
||||
}
|
||||
|
||||
return frameToPayload(frame);
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming nebula drift to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms)`
|
||||
);
|
||||
|
||||
95
test/pixelstream/ocean-glimmer.js
Normal file
95
test/pixelstream/ocean-glimmer.js
Normal file
@@ -0,0 +1,95 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
clamp,
|
||||
createFrame,
|
||||
frameToPayload,
|
||||
hexToRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 50;
|
||||
const SHIMMER = 0.08;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('031521') },
|
||||
{ stop: 0.35, color: hexToRgb('024f6d') },
|
||||
{ stop: 0.65, color: hexToRgb('13a4a1') },
|
||||
{ stop: 0.85, color: hexToRgb('67dcd0') },
|
||||
{ stop: 1.0, color: hexToRgb('fcdba4') },
|
||||
];
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node ocean-glimmer.js <device-ip> [port] [width] [height] [interval-ms]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
const frame = createFrame(width, height);
|
||||
let timeSeconds = 0;
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function generateFrame() {
|
||||
timeSeconds += frameTimeSeconds;
|
||||
|
||||
for (let row = 0; row < height; ++row) {
|
||||
const v = row / Math.max(1, height - 1);
|
||||
for (let col = 0; col < width; ++col) {
|
||||
const u = col / Math.max(1, width - 1);
|
||||
const base =
|
||||
0.33 +
|
||||
0.26 * Math.sin(u * Math.PI * 2 + timeSeconds * 1.2) +
|
||||
0.26 * Math.sin(v * Math.PI * 2 - timeSeconds * 0.9) +
|
||||
0.26 * Math.sin((u + v) * Math.PI * 2 + timeSeconds * 0.5);
|
||||
const noise = (Math.random() - 0.5) * SHIMMER;
|
||||
const value = clamp(base + noise, 0, 1);
|
||||
frame[toIndex(col, row, width)] = samplePalette(paletteStops, value);
|
||||
}
|
||||
}
|
||||
|
||||
return frameToPayload(frame);
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming ocean glimmer to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms)`,
|
||||
);
|
||||
|
||||
59
test/pixelstream/rainbow.js
Normal file
59
test/pixelstream/rainbow.js
Normal file
@@ -0,0 +1,59 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || '4210', 10);
|
||||
const pixels = parseInt(process.argv[4] || '64', 10);
|
||||
const intervalMs = parseInt(process.argv[5] || '30', 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node rainbow.js <device-ip> [port] [pixels] [interval-ms]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
let offset = 0;
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
|
||||
function wheel(pos) {
|
||||
pos = 255 - pos;
|
||||
if (pos < 85) {
|
||||
return [255 - pos * 3, 0, pos * 3];
|
||||
}
|
||||
if (pos < 170) {
|
||||
pos -= 85;
|
||||
return [0, pos * 3, 255 - pos * 3];
|
||||
}
|
||||
pos -= 170;
|
||||
return [pos * 3, 255 - pos * 3, 0];
|
||||
}
|
||||
|
||||
function generateFrame() {
|
||||
let payload = 'RAW:';
|
||||
for (let i = 0; i < pixels; i++) {
|
||||
const colorIndex = (i * 256 / pixels + offset) & 255;
|
||||
const [r, g, b] = wheel(colorIndex);
|
||||
payload += r.toString(16).padStart(2, '0');
|
||||
payload += g.toString(16).padStart(2, '0');
|
||||
payload += b.toString(16).padStart(2, '0');
|
||||
}
|
||||
|
||||
offset = (offset + 1) & 255;
|
||||
return payload;
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Streaming rainbow pattern to ${host}:${port} with ${pixels} pixels (interval=${intervalMs}ms)`);
|
||||
|
||||
118
test/pixelstream/shared-frame-utils.js
Normal file
118
test/pixelstream/shared-frame-utils.js
Normal file
@@ -0,0 +1,118 @@
|
||||
const SERPENTINE_WIRING = true;
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const normalizedHex = hex.trim().toLowerCase();
|
||||
const value = normalizedHex.startsWith('#') ? normalizedHex.slice(1) : normalizedHex;
|
||||
return {
|
||||
r: parseInt(value.slice(0, 2), 16),
|
||||
g: parseInt(value.slice(2, 4), 16),
|
||||
b: parseInt(value.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
function rgbToHex(rgb) {
|
||||
return toHex(rgb.r) + toHex(rgb.g) + toHex(rgb.b);
|
||||
}
|
||||
|
||||
function toHex(value) {
|
||||
const boundedValue = clamp(Math.round(value), 0, 255);
|
||||
const hex = boundedValue.toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
}
|
||||
|
||||
function lerpRgb(lhs, rhs, t) {
|
||||
return {
|
||||
r: Math.round(lhs.r + (rhs.r - lhs.r) * t),
|
||||
g: Math.round(lhs.g + (rhs.g - lhs.g) * t),
|
||||
b: Math.round(lhs.b + (rhs.b - lhs.b) * t),
|
||||
};
|
||||
}
|
||||
|
||||
function samplePalette(paletteStops, value) {
|
||||
const clampedValue = clamp(value, 0, 1);
|
||||
|
||||
for (let index = 0; index < paletteStops.length - 1; ++index) {
|
||||
const left = paletteStops[index];
|
||||
const right = paletteStops[index + 1];
|
||||
if (clampedValue <= right.stop) {
|
||||
const span = right.stop - left.stop || 1;
|
||||
const t = clamp((clampedValue - left.stop) / span, 0, 1);
|
||||
const interpolatedColor = lerpRgb(left.color, right.color, t);
|
||||
return rgbToHex(interpolatedColor);
|
||||
}
|
||||
}
|
||||
|
||||
return rgbToHex(paletteStops[paletteStops.length - 1].color);
|
||||
}
|
||||
|
||||
function toIndex(col, row, width, serpentine = SERPENTINE_WIRING) {
|
||||
if (!serpentine || row % 2 === 0) {
|
||||
return row * width + col;
|
||||
}
|
||||
return row * width + (width - 1 - col);
|
||||
}
|
||||
|
||||
function createFrame(width, height, fill = '000000') {
|
||||
return new Array(width * height).fill(fill);
|
||||
}
|
||||
|
||||
function frameToPayload(frame) {
|
||||
return 'RAW:' + frame.join('');
|
||||
}
|
||||
|
||||
function fadeFrame(frame, factor) {
|
||||
for (let index = 0; index < frame.length; ++index) {
|
||||
const hex = frame[index];
|
||||
const r = parseInt(hex.slice(0, 2), 16) * factor;
|
||||
const g = parseInt(hex.slice(2, 4), 16) * factor;
|
||||
const b = parseInt(hex.slice(4, 6), 16) * factor;
|
||||
frame[index] = toHex(r) + toHex(g) + toHex(b);
|
||||
}
|
||||
}
|
||||
|
||||
function addRgbToFrame(frame, index, rgb) {
|
||||
if (index < 0 || index >= frame.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = hexToRgb(frame[index]);
|
||||
const updated = {
|
||||
r: clamp(current.r + rgb.r, 0, 255),
|
||||
g: clamp(current.g + rgb.g, 0, 255),
|
||||
b: clamp(current.b + rgb.b, 0, 255),
|
||||
};
|
||||
frame[index] = rgbToHex(updated);
|
||||
}
|
||||
|
||||
function addHexColor(frame, index, hexColor, intensity = 1) {
|
||||
if (intensity <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const base = hexToRgb(hexColor);
|
||||
addRgbToFrame(frame, index, {
|
||||
r: base.r * intensity,
|
||||
g: base.g * intensity,
|
||||
b: base.b * intensity,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SERPENTINE_WIRING,
|
||||
clamp,
|
||||
hexToRgb,
|
||||
rgbToHex,
|
||||
lerpRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
createFrame,
|
||||
frameToPayload,
|
||||
fadeFrame,
|
||||
addRgbToFrame,
|
||||
addHexColor,
|
||||
};
|
||||
|
||||
1319
test/pixelstream/snek/package-lock.json
generated
Normal file
1319
test/pixelstream/snek/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
test/pixelstream/snek/package.json
Normal file
17
test/pixelstream/snek/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "snek",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"express": "^4.21.2",
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
}
|
||||
274
test/pixelstream/snek/public/app.js
Normal file
274
test/pixelstream/snek/public/app.js
Normal file
@@ -0,0 +1,274 @@
|
||||
(() => {
|
||||
const GRID_SIZE = 16;
|
||||
const TICK_MS = 120; // game speed
|
||||
|
||||
const canvas = document.getElementById('board');
|
||||
const ctx = canvas.getContext('2d', { alpha: false });
|
||||
const scoreEl = document.getElementById('score');
|
||||
const wsDot = document.getElementById('ws-dot');
|
||||
const wsLabel = document.getElementById('ws-label');
|
||||
|
||||
// NeoPixel matrix transmission config
|
||||
// Most WS2812(B) LEDs expect GRB color order. Many matrices are wired serpentine.
|
||||
const MATRIX_WIDTH = canvas.width;
|
||||
const MATRIX_HEIGHT = canvas.height;
|
||||
const COLOR_ORDER = 'RGB'; // one of: RGB, GRB, BRG, BGR, RBG, GBR
|
||||
const BRIGHTNESS = 1.0; // 0.0 .. 1.0 scalar
|
||||
const SERPENTINE = true; // true if every other row is reversed
|
||||
const FLIP_X = true; // set true if physical matrix is mirrored horizontally
|
||||
const FLIP_Y = false; // set true if physical matrix is flipped vertically
|
||||
|
||||
/** Game state */
|
||||
let snakeSegments = [];
|
||||
let currentDirection = { x: 1, y: 0 };
|
||||
let pendingDirection = { x: 1, y: 0 };
|
||||
let appleCell = null;
|
||||
let score = 0;
|
||||
let isGameOver = false;
|
||||
let sendingFrame = false;
|
||||
let ws;
|
||||
|
||||
function connectWebSocket() {
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const url = `${protocol}://${location.host}/ws`;
|
||||
ws = new WebSocket(url);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
setWsStatus('connecting');
|
||||
|
||||
ws.addEventListener('open', () => setWsStatus('open'));
|
||||
ws.addEventListener('close', () => setWsStatus('closed'));
|
||||
ws.addEventListener('error', () => setWsStatus('error'));
|
||||
}
|
||||
|
||||
function clampToByte(value) {
|
||||
if (value < 0) return 0;
|
||||
if (value > 255) return 255;
|
||||
return value | 0;
|
||||
}
|
||||
|
||||
function applyBrightness(value) {
|
||||
// value is 0..255, BRIGHTNESS is 0..1
|
||||
return clampToByte(Math.round(value * BRIGHTNESS));
|
||||
}
|
||||
|
||||
function mapXYToLinearIndex(x, y, width, height) {
|
||||
// Apply optional flips to align with physical orientation
|
||||
const mappedX = FLIP_X ? (width - 1 - x) : x;
|
||||
const mappedY = FLIP_Y ? (height - 1 - y) : y;
|
||||
|
||||
// Serpentine wiring reverses every other row (commonly odd rows)
|
||||
const isOddRow = (mappedY % 2) === 1;
|
||||
const columnInRow = (SERPENTINE && isOddRow) ? (width - 1 - mappedX) : mappedX;
|
||||
return (mappedY * width) + columnInRow;
|
||||
}
|
||||
|
||||
function writePixelWithColorOrder(target, baseIndex, r, g, b) {
|
||||
// target is a Uint8Array, baseIndex is pixelIndex * 3
|
||||
switch (COLOR_ORDER) {
|
||||
case 'RGB':
|
||||
target[baseIndex + 0] = r;
|
||||
target[baseIndex + 1] = g;
|
||||
target[baseIndex + 2] = b;
|
||||
break;
|
||||
case 'GRB':
|
||||
target[baseIndex + 0] = g;
|
||||
target[baseIndex + 1] = r;
|
||||
target[baseIndex + 2] = b;
|
||||
break;
|
||||
case 'BRG':
|
||||
target[baseIndex + 0] = b;
|
||||
target[baseIndex + 1] = r;
|
||||
target[baseIndex + 2] = g;
|
||||
break;
|
||||
case 'BGR':
|
||||
target[baseIndex + 0] = b;
|
||||
target[baseIndex + 1] = g;
|
||||
target[baseIndex + 2] = r;
|
||||
break;
|
||||
case 'RBG':
|
||||
target[baseIndex + 0] = r;
|
||||
target[baseIndex + 1] = b;
|
||||
target[baseIndex + 2] = g;
|
||||
break;
|
||||
case 'GBR':
|
||||
target[baseIndex + 0] = g;
|
||||
target[baseIndex + 1] = b;
|
||||
target[baseIndex + 2] = r;
|
||||
break;
|
||||
default:
|
||||
// Fallback to GRB if misconfigured
|
||||
target[baseIndex + 0] = g;
|
||||
target[baseIndex + 1] = r;
|
||||
target[baseIndex + 2] = b;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeCanvasToNeoPixelFrame() {
|
||||
// Produces headerless frame: width*height pixels, 3 bytes per pixel in COLOR_ORDER
|
||||
const width = MATRIX_WIDTH;
|
||||
const height = MATRIX_HEIGHT;
|
||||
const out = new Uint8Array(width * height * 3);
|
||||
const src = ctx.getImageData(0, 0, width, height).data; // RGBA
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const srcIndex = ((y * width) + x) * 4;
|
||||
const r = applyBrightness(src[srcIndex + 0]);
|
||||
const g = applyBrightness(src[srcIndex + 1]);
|
||||
const b = applyBrightness(src[srcIndex + 2]);
|
||||
|
||||
const pixelIndex = mapXYToLinearIndex(x, y, width, height);
|
||||
const base = pixelIndex * 3;
|
||||
writePixelWithColorOrder(out, base, r, g, b);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function setWsStatus(state) {
|
||||
if (state === 'open') {
|
||||
wsDot.classList.add('ok');
|
||||
wsLabel.textContent = 'WS: connected';
|
||||
} else if (state === 'connecting') {
|
||||
wsDot.classList.remove('ok');
|
||||
wsLabel.textContent = 'WS: connecting…';
|
||||
} else if (state === 'closed') {
|
||||
wsDot.classList.remove('ok');
|
||||
wsLabel.textContent = 'WS: disconnected';
|
||||
// try to reconnect after a delay
|
||||
setTimeout(connectWebSocket, 1000);
|
||||
} else {
|
||||
wsDot.classList.remove('ok');
|
||||
wsLabel.textContent = 'WS: error';
|
||||
}
|
||||
}
|
||||
|
||||
function resetGame() {
|
||||
snakeSegments = [ { x: 4, y: 8 }, { x: 3, y: 8 }, { x: 2, y: 8 } ];
|
||||
currentDirection = { x: 1, y: 0 };
|
||||
pendingDirection = { x: 1, y: 0 };
|
||||
score = 0;
|
||||
scoreEl.textContent = String(score);
|
||||
isGameOver = false;
|
||||
placeApple();
|
||||
}
|
||||
|
||||
function placeApple() {
|
||||
const occupied = new Set(snakeSegments.map(c => `${c.x},${c.y}`));
|
||||
let x, y;
|
||||
do {
|
||||
x = Math.floor(Math.random() * GRID_SIZE);
|
||||
y = Math.floor(Math.random() * GRID_SIZE);
|
||||
} while (occupied.has(`${x},${y}`));
|
||||
appleCell = { x, y };
|
||||
}
|
||||
|
||||
function stepGame() {
|
||||
if (isGameOver) return;
|
||||
|
||||
// Commit pending direction (prevents double-turning in one tick)
|
||||
if ((pendingDirection.x !== -currentDirection.x) || (pendingDirection.y !== -currentDirection.y)) {
|
||||
currentDirection = pendingDirection;
|
||||
}
|
||||
|
||||
const head = snakeSegments[0];
|
||||
const newHead = { x: head.x + currentDirection.x, y: head.y + currentDirection.y };
|
||||
|
||||
// Wall collision
|
||||
if (newHead.x < 0 || newHead.x >= GRID_SIZE || newHead.y < 0 || newHead.y >= GRID_SIZE) {
|
||||
isGameOver = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Self collision
|
||||
for (let i = 0; i < snakeSegments.length; i++) {
|
||||
const seg = snakeSegments[i];
|
||||
if (seg.x === newHead.x && seg.y === newHead.y) {
|
||||
isGameOver = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Move snake
|
||||
snakeSegments.unshift(newHead);
|
||||
const ateApple = (newHead.x === appleCell.x && newHead.y === appleCell.y);
|
||||
if (ateApple) {
|
||||
score += 1;
|
||||
scoreEl.textContent = String(score);
|
||||
placeApple();
|
||||
} else {
|
||||
snakeSegments.pop();
|
||||
}
|
||||
}
|
||||
|
||||
function renderGame() {
|
||||
// Background
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Apple
|
||||
ctx.fillStyle = '#d23';
|
||||
ctx.fillRect(appleCell.x, appleCell.y, 1, 1);
|
||||
|
||||
// Snake
|
||||
ctx.fillStyle = '#3bd16f';
|
||||
for (const cell of snakeSegments) {
|
||||
ctx.fillRect(cell.x, cell.y, 1, 1);
|
||||
}
|
||||
|
||||
if (isGameOver) {
|
||||
// Simple overlay pixel art for game over (draw a cross)
|
||||
ctx.fillStyle = '#f33';
|
||||
for (let i = 0; i < GRID_SIZE; i++) {
|
||||
ctx.fillRect(i, i, 1, 1);
|
||||
ctx.fillRect(GRID_SIZE - 1 - i, i, 1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
if (sendingFrame) return;
|
||||
sendingFrame = true;
|
||||
try {
|
||||
const frame = encodeCanvasToNeoPixelFrame();
|
||||
// Send ArrayBuffer view; receivers expect raw 3-byte-per-pixel stream
|
||||
ws.send(frame.buffer);
|
||||
} catch (_) {
|
||||
// ignore
|
||||
} finally {
|
||||
sendingFrame = false;
|
||||
}
|
||||
}
|
||||
|
||||
function gameLoop() {
|
||||
stepGame();
|
||||
renderGame();
|
||||
sendFrame();
|
||||
}
|
||||
|
||||
// Controls
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === ' ') {
|
||||
resetGame();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp' && currentDirection.y !== 1) {
|
||||
pendingDirection = { x: 0, y: -1 };
|
||||
} else if (e.key === 'ArrowDown' && currentDirection.y !== -1) {
|
||||
pendingDirection = { x: 0, y: 1 };
|
||||
} else if (e.key === 'ArrowLeft' && currentDirection.x !== 1) {
|
||||
pendingDirection = { x: -1, y: 0 };
|
||||
} else if (e.key === 'ArrowRight' && currentDirection.x !== -1) {
|
||||
pendingDirection = { x: 1, y: 0 };
|
||||
}
|
||||
});
|
||||
|
||||
// Start
|
||||
resetGame();
|
||||
connectWebSocket();
|
||||
setInterval(gameLoop, TICK_MS);
|
||||
})();
|
||||
59
test/pixelstream/snek/public/index.html
Normal file
59
test/pixelstream/snek/public/index.html
Normal file
@@ -0,0 +1,59 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Snek 16x16</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
background: #0b0b0b;
|
||||
color: #e8e8e8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 24px 12px;
|
||||
}
|
||||
h2 { margin: 0 0 4px 0; font-weight: 600; }
|
||||
#board {
|
||||
width: 640px; /* make it big */
|
||||
height: 640px; /* make it big */
|
||||
image-rendering: pixelated;
|
||||
border: 1px solid #333;
|
||||
background: #000;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||
}
|
||||
.hud {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.status {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; background: #a33; }
|
||||
.dot.ok { background: #3a3; }
|
||||
.pill { padding: 2px 8px; border: 1px solid #333; border-radius: 999px; font-size: 12px; }
|
||||
.hint { color: #aaa; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>16×16 Snek</h2>
|
||||
<div class="hud">
|
||||
<div>Score: <span id="score">0</span></div>
|
||||
<div class="status"><span class="dot" id="ws-dot"></span><span class="pill" id="ws-label">WS: connecting…</span></div>
|
||||
</div>
|
||||
<canvas id="board" width="16" height="16"></canvas>
|
||||
<div class="hint">Arrow keys to move. Space to restart.</div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
83
test/pixelstream/snek/server.js
Normal file
83
test/pixelstream/snek/server.js
Normal file
@@ -0,0 +1,83 @@
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
const dgram = require('dgram');
|
||||
const { WebSocketServer } = require('ws');
|
||||
|
||||
const HTTP_PORT = process.env.PORT || 3000;
|
||||
// UDP settings
|
||||
// Default broadcast; override with unicast address via UDP_ADDR if you have a single receiver
|
||||
const UDP_BROADCAST_PORT = Number(process.env.UDP_PORT) || 4210;
|
||||
const UDP_BROADCAST_ADDR = process.env.UDP_ADDR || '255.255.255.255';
|
||||
|
||||
// NeoPixel frame properties for basic validation/logging (no transformation here)
|
||||
const MATRIX_WIDTH = Number(process.env.MATRIX_WIDTH) || 16;
|
||||
const MATRIX_HEIGHT = Number(process.env.MATRIX_HEIGHT) || 16;
|
||||
const BYTES_PER_PIXEL = 3; // GRB without alpha
|
||||
|
||||
const app = express();
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
const udpSocket = dgram.createSocket('udp4');
|
||||
udpSocket.on('error', (err) => {
|
||||
console.error('[UDP] error:', err);
|
||||
});
|
||||
udpSocket.bind(() => {
|
||||
try {
|
||||
udpSocket.setBroadcast(true);
|
||||
console.log(`[UDP] Ready to broadcast on ${UDP_BROADCAST_ADDR}:${UDP_BROADCAST_PORT}`);
|
||||
} catch (e) {
|
||||
console.error('[UDP] setBroadcast failed:', e);
|
||||
}
|
||||
});
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
const clientAddress = req?.socket?.remoteAddress || 'unknown';
|
||||
console.log(`[WS] Client connected: ${clientAddress}`);
|
||||
|
||||
ws.on('message', (data) => {
|
||||
const bufferToSend = Buffer.isBuffer(data)
|
||||
? data
|
||||
: (data instanceof ArrayBuffer ? Buffer.from(data) : Buffer.from(String(data)));
|
||||
|
||||
const expectedSize = MATRIX_WIDTH * MATRIX_HEIGHT * BYTES_PER_PIXEL;
|
||||
if (bufferToSend.length !== expectedSize) {
|
||||
console.warn(`[WS] Unexpected frame size: ${bufferToSend.length} bytes (expected ${expectedSize}).`);
|
||||
}
|
||||
|
||||
const hexPayload = bufferToSend.toString('hex');
|
||||
const udpPayload = Buffer.from(`RAW:${hexPayload}`, 'ascii');
|
||||
|
||||
udpSocket.send(
|
||||
udpPayload,
|
||||
UDP_BROADCAST_PORT,
|
||||
UDP_BROADCAST_ADDR,
|
||||
(err) => {
|
||||
if (err) {
|
||||
console.error('[UDP] send error:', err.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('[WS] Client disconnected');
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('[WS] error:', err.message);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(HTTP_PORT, () => {
|
||||
console.log(`Server listening on http://localhost:${HTTP_PORT}`);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('Shutting down...');
|
||||
try { udpSocket.close(); } catch {}
|
||||
try { server.close(() => process.exit(0)); } catch {}
|
||||
});
|
||||
97
test/pixelstream/spiral-bloom.js
Normal file
97
test/pixelstream/spiral-bloom.js
Normal file
@@ -0,0 +1,97 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
createFrame,
|
||||
frameToPayload,
|
||||
hexToRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 55;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('051923') },
|
||||
{ stop: 0.2, color: hexToRgb('0c4057') },
|
||||
{ stop: 0.45, color: hexToRgb('1d7a70') },
|
||||
{ stop: 0.7, color: hexToRgb('39b15f') },
|
||||
{ stop: 0.88, color: hexToRgb('9dd54c') },
|
||||
{ stop: 1.0, color: hexToRgb('f7f5bc') },
|
||||
];
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node spiral-bloom.js <device-ip> [port] [width] [height] [interval-ms]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
const frame = createFrame(width, height);
|
||||
let rotation = 0;
|
||||
let hueShift = 0;
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function generateFrame() {
|
||||
rotation += frameTimeSeconds * 0.7;
|
||||
hueShift += frameTimeSeconds * 0.2;
|
||||
|
||||
const cx = (width - 1) / 2;
|
||||
const cy = (height - 1) / 2;
|
||||
const radiusNorm = Math.hypot(cx, cy) || 1;
|
||||
|
||||
for (let row = 0; row < height; ++row) {
|
||||
for (let col = 0; col < width; ++col) {
|
||||
const dx = col - cx;
|
||||
const dy = row - cy;
|
||||
const radius = Math.hypot(dx, dy) / radiusNorm;
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const arm = 0.5 + 0.5 * Math.sin(5 * (angle + rotation) + hueShift * Math.PI * 2);
|
||||
const value = Math.min(1, radius * 0.8 + arm * 0.4);
|
||||
frame[toIndex(col, row, width)] = samplePalette(paletteStops, value);
|
||||
}
|
||||
}
|
||||
|
||||
return frameToPayload(frame);
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming spiral bloom to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms)`,
|
||||
);
|
||||
|
||||
855
test/pixelstream/tetris/package-lock.json
generated
Normal file
855
test/pixelstream/tetris/package-lock.json
generated
Normal file
@@ -0,0 +1,855 @@
|
||||
{
|
||||
"name": "tetris",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "tetris",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"express": "^4.21.2",
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.3",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.4.24",
|
||||
"on-finished": "2.4.1",
|
||||
"qs": "6.13.0",
|
||||
"raw-body": "2.5.2",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.21.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "1.20.3",
|
||||
"content-disposition": "0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.7.1",
|
||||
"cookie-signature": "1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "1.3.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "6.13.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "0.19.0",
|
||||
"serve-static": "1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
|
||||
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"statuses": "2.0.1",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "2.0.0",
|
||||
"inherits": "2.0.4",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"toidentifier": "1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
|
||||
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.4.24",
|
||||
"unpipe": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
|
||||
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"encodeurl": "~1.0.2",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.3",
|
||||
"on-finished": "2.4.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/encodeurl": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
|
||||
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.2",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
|
||||
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "0.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
test/pixelstream/tetris/package.json
Normal file
18
test/pixelstream/tetris/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "tetris",
|
||||
"version": "1.0.0",
|
||||
"description": "16x16 Tetris pixelstream demo",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"express": "^4.21.2",
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
}
|
||||
|
||||
563
test/pixelstream/tetris/public/app.js
Normal file
563
test/pixelstream/tetris/public/app.js
Normal file
@@ -0,0 +1,563 @@
|
||||
(() => {
|
||||
const GRID_WIDTH = 16;
|
||||
const GRID_HEIGHT = 16;
|
||||
const FRAME_INTERVAL_MS = 50;
|
||||
const AUTO_DROP_MS = 600;
|
||||
const SOFT_DROP_MS = 60;
|
||||
|
||||
const canvas = document.getElementById('board');
|
||||
const ctx = canvas.getContext('2d', { alpha: false });
|
||||
const scoreEl = document.getElementById('score');
|
||||
const linesEl = document.getElementById('lines');
|
||||
const wsDot = document.getElementById('ws-dot');
|
||||
const wsLabel = document.getElementById('ws-label');
|
||||
|
||||
const MATRIX_WIDTH = canvas.width;
|
||||
const MATRIX_HEIGHT = canvas.height;
|
||||
const COLOR_ORDER = 'RGB';
|
||||
const BRIGHTNESS = 1.0;
|
||||
const SERPENTINE = true;
|
||||
const FLIP_X = true;
|
||||
const FLIP_Y = false;
|
||||
|
||||
const TETROMINOES = [
|
||||
{
|
||||
name: 'I',
|
||||
color: '#2dd9ff',
|
||||
rotations: [
|
||||
[ [0, 1], [1, 1], [2, 1], [3, 1] ],
|
||||
[ [2, 0], [2, 1], [2, 2], [2, 3] ],
|
||||
[ [0, 2], [1, 2], [2, 2], [3, 2] ],
|
||||
[ [1, 0], [1, 1], [1, 2], [1, 3] ],
|
||||
],
|
||||
spawnOffset: { x: 6, y: -1 },
|
||||
},
|
||||
{
|
||||
name: 'J',
|
||||
color: '#3b7ddd',
|
||||
rotations: [
|
||||
[ [0, 0], [0, 1], [1, 1], [2, 1] ],
|
||||
[ [1, 0], [2, 0], [1, 1], [1, 2] ],
|
||||
[ [0, 1], [1, 1], [2, 1], [2, 2] ],
|
||||
[ [1, 0], [1, 1], [0, 2], [1, 2] ],
|
||||
],
|
||||
spawnOffset: { x: 6, y: -1 },
|
||||
},
|
||||
{
|
||||
name: 'L',
|
||||
color: '#f7b733',
|
||||
rotations: [
|
||||
[ [2, 0], [0, 1], [1, 1], [2, 1] ],
|
||||
[ [1, 0], [1, 1], [1, 2], [2, 2] ],
|
||||
[ [0, 1], [1, 1], [2, 1], [0, 2] ],
|
||||
[ [0, 0], [1, 0], [1, 1], [1, 2] ],
|
||||
],
|
||||
spawnOffset: { x: 6, y: -1 },
|
||||
},
|
||||
{
|
||||
name: 'O',
|
||||
color: '#ffe66d',
|
||||
rotations: [
|
||||
[ [1, 0], [2, 0], [1, 1], [2, 1] ],
|
||||
[ [1, 0], [2, 0], [1, 1], [2, 1] ],
|
||||
[ [1, 0], [2, 0], [1, 1], [2, 1] ],
|
||||
[ [1, 0], [2, 0], [1, 1], [2, 1] ],
|
||||
],
|
||||
spawnOffset: { x: 6, y: -1 },
|
||||
},
|
||||
{
|
||||
name: 'S',
|
||||
color: '#30db6d',
|
||||
rotations: [
|
||||
[ [1, 0], [2, 0], [0, 1], [1, 1] ],
|
||||
[ [1, 0], [1, 1], [2, 1], [2, 2] ],
|
||||
[ [1, 1], [2, 1], [0, 2], [1, 2] ],
|
||||
[ [0, 0], [0, 1], [1, 1], [1, 2] ],
|
||||
],
|
||||
spawnOffset: { x: 6, y: -1 },
|
||||
},
|
||||
{
|
||||
name: 'T',
|
||||
color: '#c86bff',
|
||||
rotations: [
|
||||
[ [1, 0], [0, 1], [1, 1], [2, 1] ],
|
||||
[ [1, 0], [1, 1], [2, 1], [1, 2] ],
|
||||
[ [0, 1], [1, 1], [2, 1], [1, 2] ],
|
||||
[ [1, 0], [0, 1], [1, 1], [1, 2] ],
|
||||
],
|
||||
spawnOffset: { x: 6, y: -1 },
|
||||
},
|
||||
{
|
||||
name: 'Z',
|
||||
color: '#ff5f5f',
|
||||
rotations: [
|
||||
[ [0, 0], [1, 0], [1, 1], [2, 1] ],
|
||||
[ [2, 0], [1, 1], [2, 1], [1, 2] ],
|
||||
[ [0, 1], [1, 1], [1, 2], [2, 2] ],
|
||||
[ [1, 0], [0, 1], [1, 1], [0, 2] ],
|
||||
],
|
||||
spawnOffset: { x: 6, y: -1 },
|
||||
},
|
||||
];
|
||||
|
||||
const LINE_SCORE_TABLE = {
|
||||
1: 100,
|
||||
2: 250,
|
||||
3: 400,
|
||||
4: 800,
|
||||
};
|
||||
|
||||
let ws;
|
||||
let wsReady = false;
|
||||
let sendingFrame = false;
|
||||
|
||||
let board;
|
||||
let currentPiece;
|
||||
let holdQueue;
|
||||
let score;
|
||||
let clearedLines;
|
||||
let isGameOver;
|
||||
let dropTimer;
|
||||
let softDropActive;
|
||||
|
||||
function connectWebSocket() {
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const url = `${protocol}://${location.host}/ws`;
|
||||
ws = new WebSocket(url);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
setWsStatus('connecting');
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
wsReady = true;
|
||||
setWsStatus('open');
|
||||
});
|
||||
ws.addEventListener('close', () => {
|
||||
wsReady = false;
|
||||
setWsStatus('closed');
|
||||
});
|
||||
ws.addEventListener('error', () => {
|
||||
wsReady = false;
|
||||
setWsStatus('error');
|
||||
});
|
||||
}
|
||||
|
||||
function setWsStatus(state) {
|
||||
if (state === 'open') {
|
||||
wsDot.classList.add('ok');
|
||||
wsLabel.textContent = 'WS: connected';
|
||||
} else if (state === 'connecting') {
|
||||
wsDot.classList.remove('ok');
|
||||
wsLabel.textContent = 'WS: connecting…';
|
||||
} else if (state === 'closed') {
|
||||
wsDot.classList.remove('ok');
|
||||
wsLabel.textContent = 'WS: disconnected';
|
||||
setTimeout(connectWebSocket, 1000);
|
||||
} else {
|
||||
wsDot.classList.remove('ok');
|
||||
wsLabel.textContent = 'WS: error';
|
||||
}
|
||||
}
|
||||
|
||||
function clampToByte(value) {
|
||||
if (value < 0) return 0;
|
||||
if (value > 255) return 255;
|
||||
return value | 0;
|
||||
}
|
||||
|
||||
function applyBrightness(value) {
|
||||
return clampToByte(Math.round(value * BRIGHTNESS));
|
||||
}
|
||||
|
||||
function mapXYToLinearIndex(x, y, width, height) {
|
||||
const mappedX = FLIP_X ? (width - 1 - x) : x;
|
||||
const mappedY = FLIP_Y ? (height - 1 - y) : y;
|
||||
const isOddRow = (mappedY % 2) === 1;
|
||||
const columnInRow = (SERPENTINE && isOddRow) ? (width - 1 - mappedX) : mappedX;
|
||||
return (mappedY * width) + columnInRow;
|
||||
}
|
||||
|
||||
function writePixelWithColorOrder(target, baseIndex, r, g, b) {
|
||||
switch (COLOR_ORDER) {
|
||||
case 'RGB':
|
||||
target[baseIndex + 0] = r;
|
||||
target[baseIndex + 1] = g;
|
||||
target[baseIndex + 2] = b;
|
||||
break;
|
||||
case 'GRB':
|
||||
target[baseIndex + 0] = g;
|
||||
target[baseIndex + 1] = r;
|
||||
target[baseIndex + 2] = b;
|
||||
break;
|
||||
case 'BRG':
|
||||
target[baseIndex + 0] = b;
|
||||
target[baseIndex + 1] = r;
|
||||
target[baseIndex + 2] = g;
|
||||
break;
|
||||
case 'BGR':
|
||||
target[baseIndex + 0] = b;
|
||||
target[baseIndex + 1] = g;
|
||||
target[baseIndex + 2] = r;
|
||||
break;
|
||||
case 'RBG':
|
||||
target[baseIndex + 0] = r;
|
||||
target[baseIndex + 1] = b;
|
||||
target[baseIndex + 2] = g;
|
||||
break;
|
||||
case 'GBR':
|
||||
target[baseIndex + 0] = g;
|
||||
target[baseIndex + 1] = b;
|
||||
target[baseIndex + 2] = r;
|
||||
break;
|
||||
default:
|
||||
target[baseIndex + 0] = g;
|
||||
target[baseIndex + 1] = r;
|
||||
target[baseIndex + 2] = b;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeCanvasToNeoPixelFrame() {
|
||||
const width = MATRIX_WIDTH;
|
||||
const height = MATRIX_HEIGHT;
|
||||
const out = new Uint8Array(width * height * 3);
|
||||
const src = ctx.getImageData(0, 0, width, height).data;
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const srcIndex = ((y * width) + x) * 4;
|
||||
const r = applyBrightness(src[srcIndex + 0]);
|
||||
const g = applyBrightness(src[srcIndex + 1]);
|
||||
const b = applyBrightness(src[srcIndex + 2]);
|
||||
|
||||
const pixelIndex = mapXYToLinearIndex(x, y, width, height);
|
||||
const base = pixelIndex * 3;
|
||||
writePixelWithColorOrder(out, base, r, g, b);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function createEmptyBoard() {
|
||||
const rows = [];
|
||||
for (let y = 0; y < GRID_HEIGHT; y++) {
|
||||
rows.push(new Array(GRID_WIDTH).fill(null));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function makePiece(template) {
|
||||
return {
|
||||
name: template.name,
|
||||
color: template.color,
|
||||
rotations: template.rotations,
|
||||
rotationIndex: 0,
|
||||
x: template.spawnOffset.x,
|
||||
y: template.spawnOffset.y,
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentShape(piece) {
|
||||
return piece.rotations[piece.rotationIndex];
|
||||
}
|
||||
|
||||
function forEachBlock(piece, callback) {
|
||||
const shape = getCurrentShape(piece);
|
||||
for (let i = 0; i < shape.length; i++) {
|
||||
const [dx, dy] = shape[i];
|
||||
callback(piece.x + dx, piece.y + dy);
|
||||
}
|
||||
}
|
||||
|
||||
function isInside(x, y) {
|
||||
return x >= 0 && x < GRID_WIDTH && y < GRID_HEIGHT;
|
||||
}
|
||||
|
||||
function isCellFree(x, y) {
|
||||
if (y < 0) {
|
||||
return true;
|
||||
}
|
||||
if (!isInside(x, y)) {
|
||||
return false;
|
||||
}
|
||||
return board[y][x] === null;
|
||||
}
|
||||
|
||||
function canPlace(piece, offsetX, offsetY, rotationDelta) {
|
||||
const nextRotation = (piece.rotationIndex + rotationDelta + piece.rotations.length) % piece.rotations.length;
|
||||
const shape = piece.rotations[nextRotation];
|
||||
for (let i = 0; i < shape.length; i++) {
|
||||
const [dx, dy] = shape[i];
|
||||
const x = piece.x + offsetX + dx;
|
||||
const y = piece.y + offsetY + dy;
|
||||
if (!isCellFree(x, y)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function commitPieceToBoard(piece) {
|
||||
const shape = getCurrentShape(piece);
|
||||
for (let i = 0; i < shape.length; i++) {
|
||||
const [dx, dy] = shape[i];
|
||||
const x = piece.x + dx;
|
||||
const y = piece.y + dy;
|
||||
if (y >= 0 && y < GRID_HEIGHT && x >= 0 && x < GRID_WIDTH) {
|
||||
board[y][x] = piece.color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearLines() {
|
||||
let linesCleared = 0;
|
||||
const newRows = [];
|
||||
for (let y = 0; y < GRID_HEIGHT; y++) {
|
||||
if (board[y].every(cell => cell !== null)) {
|
||||
linesCleared += 1;
|
||||
} else {
|
||||
newRows.push(board[y]);
|
||||
}
|
||||
}
|
||||
|
||||
while (newRows.length < GRID_HEIGHT) {
|
||||
newRows.unshift(new Array(GRID_WIDTH).fill(null));
|
||||
}
|
||||
|
||||
board = newRows;
|
||||
|
||||
if (linesCleared > 0) {
|
||||
clearedLines += linesCleared;
|
||||
const awarded = LINE_SCORE_TABLE[linesCleared] || (linesCleared * 200);
|
||||
score += awarded;
|
||||
}
|
||||
}
|
||||
|
||||
function spawnNextPiece() {
|
||||
if (holdQueue.length === 0) {
|
||||
refillBag();
|
||||
}
|
||||
currentPiece = makePiece(holdQueue.shift());
|
||||
if (!canPlace(currentPiece, 0, 0, 0)) {
|
||||
isGameOver = true;
|
||||
}
|
||||
}
|
||||
|
||||
function refillBag() {
|
||||
const bag = [...TETROMINOES];
|
||||
for (let i = bag.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[bag[i], bag[j]] = [bag[j], bag[i]];
|
||||
}
|
||||
holdQueue.push(...bag);
|
||||
}
|
||||
|
||||
function tryMove(offsetX, offsetY) {
|
||||
if (!currentPiece || !canPlace(currentPiece, offsetX, offsetY, 0)) {
|
||||
return false;
|
||||
}
|
||||
currentPiece.x += offsetX;
|
||||
currentPiece.y += offsetY;
|
||||
return true;
|
||||
}
|
||||
|
||||
function tryRotate(clockwise = true) {
|
||||
if (!currentPiece) {
|
||||
return;
|
||||
}
|
||||
const delta = clockwise ? 1 : -1;
|
||||
const originalRotation = currentPiece.rotationIndex;
|
||||
const targetRotation = (originalRotation + delta + currentPiece.rotations.length) % currentPiece.rotations.length;
|
||||
|
||||
const kicks = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: -1, y: 0 },
|
||||
{ x: 1, y: 0 },
|
||||
{ x: -2, y: 0 },
|
||||
{ x: 2, y: 0 },
|
||||
{ x: 0, y: -1 },
|
||||
];
|
||||
|
||||
for (const kick of kicks) {
|
||||
if (canPlace(currentPiece, kick.x, kick.y, delta)) {
|
||||
currentPiece.rotationIndex = targetRotation;
|
||||
currentPiece.x += kick.x;
|
||||
currentPiece.y += kick.y;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hardDrop() {
|
||||
if (!currentPiece) {
|
||||
return;
|
||||
}
|
||||
while (tryMove(0, 1)) {
|
||||
score += 2;
|
||||
}
|
||||
lockPiece();
|
||||
}
|
||||
|
||||
function lockPiece() {
|
||||
commitPieceToBoard(currentPiece);
|
||||
clearLines();
|
||||
scoreEl.textContent = String(score);
|
||||
linesEl.textContent = String(clearedLines);
|
||||
spawnNextPiece();
|
||||
dropTimer = 0;
|
||||
}
|
||||
|
||||
function updateGame(deltaMs) {
|
||||
if (isGameOver) {
|
||||
return;
|
||||
}
|
||||
|
||||
dropTimer += deltaMs;
|
||||
const targetDrop = softDropActive ? SOFT_DROP_MS : AUTO_DROP_MS;
|
||||
|
||||
if (dropTimer >= targetDrop) {
|
||||
dropTimer = 0;
|
||||
if (!tryMove(0, 1)) {
|
||||
lockPiece();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawBoard() {
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
for (let y = 0; y < GRID_HEIGHT; y++) {
|
||||
for (let x = 0; x < GRID_WIDTH; x++) {
|
||||
const cell = board[y][x];
|
||||
if (cell) {
|
||||
ctx.fillStyle = cell;
|
||||
ctx.fillRect(x, y, 1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentPiece) {
|
||||
ctx.fillStyle = currentPiece.color;
|
||||
forEachBlock(currentPiece, (x, y) => {
|
||||
if (y >= 0 && y < GRID_HEIGHT && x >= 0 && x < GRID_WIDTH) {
|
||||
ctx.fillRect(x, y, 1, 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (isGameOver) {
|
||||
ctx.fillStyle = '#ff3b30';
|
||||
for (let x = 3; x < GRID_WIDTH - 3; x++) {
|
||||
ctx.fillRect(x, 6, 1, 1);
|
||||
ctx.fillRect(x, 9, 1, 1);
|
||||
}
|
||||
for (let y = 6; y <= 9; y++) {
|
||||
ctx.fillRect(3, y, 1, 1);
|
||||
ctx.fillRect(GRID_WIDTH - 4, y, 1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toHex(value) {
|
||||
const bounded = Math.max(0, Math.min(255, value));
|
||||
const hex = bounded.toString(16);
|
||||
return hex.length === 1 ? `0${hex}` : hex;
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
if (!wsReady || !ws || ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
if (sendingFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendingFrame = true;
|
||||
try {
|
||||
const frame = encodeCanvasToNeoPixelFrame();
|
||||
ws.send(frame.buffer);
|
||||
} catch (_) {
|
||||
// ignore
|
||||
} finally {
|
||||
sendingFrame = false;
|
||||
}
|
||||
}
|
||||
|
||||
let previousTimestamp = performance.now();
|
||||
|
||||
function gameLoop() {
|
||||
const now = performance.now();
|
||||
const delta = now - previousTimestamp;
|
||||
previousTimestamp = now;
|
||||
|
||||
updateGame(delta);
|
||||
drawBoard();
|
||||
sendFrame();
|
||||
|
||||
setTimeout(gameLoop, FRAME_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function resetGame() {
|
||||
board = createEmptyBoard();
|
||||
holdQueue = [];
|
||||
score = 0;
|
||||
clearedLines = 0;
|
||||
isGameOver = false;
|
||||
dropTimer = 0;
|
||||
softDropActive = false;
|
||||
scoreEl.textContent = '0';
|
||||
linesEl.textContent = '0';
|
||||
refillBag();
|
||||
spawnNextPiece();
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (isGameOver && event.key.toLowerCase() === 'r') {
|
||||
resetGame();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault();
|
||||
tryMove(-1, 0);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
event.preventDefault();
|
||||
tryMove(1, 0);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
softDropActive = true;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
tryRotate(true);
|
||||
break;
|
||||
case ' ': // Space hard drop
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
hardDrop();
|
||||
break;
|
||||
case 'r':
|
||||
case 'R':
|
||||
event.preventDefault();
|
||||
resetGame();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('keyup', (event) => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
softDropActive = false;
|
||||
}
|
||||
});
|
||||
|
||||
resetGame();
|
||||
connectWebSocket();
|
||||
gameLoop();
|
||||
})();
|
||||
|
||||
61
test/pixelstream/tetris/public/index.html
Normal file
61
test/pixelstream/tetris/public/index.html
Normal file
@@ -0,0 +1,61 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>16×16 Tetris</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
background: #0b0b0b;
|
||||
color: #e8e8e8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 24px 12px;
|
||||
}
|
||||
h2 { margin: 0 0 4px 0; font-weight: 600; }
|
||||
#board {
|
||||
width: 640px;
|
||||
height: 640px;
|
||||
image-rendering: pixelated;
|
||||
border: 1px solid #333;
|
||||
background: #000;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.hud {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.status {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; background: #a33; }
|
||||
.dot.ok { background: #3a3; }
|
||||
.pill { padding: 2px 8px; border: 1px solid #333; border-radius: 999px; font-size: 12px; }
|
||||
.hint { color: #aaa; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>16×16 Tetris</h2>
|
||||
<div class="hud">
|
||||
<div>Score: <span id="score">0</span></div>
|
||||
<div>Lines: <span id="lines">0</span></div>
|
||||
<div class="status"><span class="dot" id="ws-dot"></span><span class="pill" id="ws-label">WS: connecting…</span></div>
|
||||
</div>
|
||||
<canvas id="board" width="16" height="16"></canvas>
|
||||
<div class="hint">Arrow keys to move, Up to rotate, Space or Enter to hard drop, R to restart.</div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
81
test/pixelstream/tetris/server.js
Normal file
81
test/pixelstream/tetris/server.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
const dgram = require('dgram');
|
||||
const { WebSocketServer } = require('ws');
|
||||
|
||||
const HTTP_PORT = process.env.PORT || 3000;
|
||||
const UDP_BROADCAST_PORT = Number(process.env.UDP_PORT) || 4210;
|
||||
const UDP_BROADCAST_ADDR = process.env.UDP_ADDR || '255.255.255.255';
|
||||
|
||||
const MATRIX_WIDTH = Number(process.env.MATRIX_WIDTH) || 16;
|
||||
const MATRIX_HEIGHT = Number(process.env.MATRIX_HEIGHT) || 16;
|
||||
const BYTES_PER_PIXEL = 3;
|
||||
|
||||
const app = express();
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
const udpSocket = dgram.createSocket('udp4');
|
||||
udpSocket.on('error', (err) => {
|
||||
console.error('[UDP] error:', err);
|
||||
});
|
||||
udpSocket.bind(() => {
|
||||
try {
|
||||
udpSocket.setBroadcast(true);
|
||||
console.log(`[UDP] Ready to broadcast on ${UDP_BROADCAST_ADDR}:${UDP_BROADCAST_PORT}`);
|
||||
} catch (e) {
|
||||
console.error('[UDP] setBroadcast failed:', e);
|
||||
}
|
||||
});
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
const clientAddress = req?.socket?.remoteAddress || 'unknown';
|
||||
console.log(`[WS] Client connected: ${clientAddress}`);
|
||||
|
||||
ws.on('message', (data) => {
|
||||
const bufferToSend = Buffer.isBuffer(data)
|
||||
? data
|
||||
: (data instanceof ArrayBuffer ? Buffer.from(data) : Buffer.from(String(data)));
|
||||
|
||||
const expectedSize = MATRIX_WIDTH * MATRIX_HEIGHT * BYTES_PER_PIXEL;
|
||||
if (bufferToSend.length !== expectedSize) {
|
||||
console.warn(`[WS] Unexpected frame size: ${bufferToSend.length} bytes (expected ${expectedSize}).`);
|
||||
}
|
||||
|
||||
const hexPayload = bufferToSend.toString('hex');
|
||||
const udpPayload = Buffer.from(`RAW:${hexPayload}`, 'ascii');
|
||||
|
||||
udpSocket.send(
|
||||
udpPayload,
|
||||
UDP_BROADCAST_PORT,
|
||||
UDP_BROADCAST_ADDR,
|
||||
(err) => {
|
||||
if (err) {
|
||||
console.error('[UDP] send error:', err.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('[WS] Client disconnected');
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('[WS] error:', err.message);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(HTTP_PORT, () => {
|
||||
console.log(`Server listening on http://localhost:${HTTP_PORT}`);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('Shutting down...');
|
||||
try { udpSocket.close(); } catch {}
|
||||
try { server.close(() => process.exit(0)); } catch {}
|
||||
});
|
||||
|
||||
165
test/pixelstream/voxel-fireflies.js
Normal file
165
test/pixelstream/voxel-fireflies.js
Normal file
@@ -0,0 +1,165 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
addHexColor,
|
||||
clamp,
|
||||
createFrame,
|
||||
fadeFrame,
|
||||
frameToPayload,
|
||||
hexToRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 55;
|
||||
const DEFAULT_FIREFLY_COUNT = 18;
|
||||
const HOVER_SPEED = 0.6;
|
||||
const GLOW_SPEED = 1.8;
|
||||
const TRAIL_DECAY = 0.8;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('02030a') },
|
||||
{ stop: 0.2, color: hexToRgb('031c2d') },
|
||||
{ stop: 0.4, color: hexToRgb('053d4a') },
|
||||
{ stop: 0.6, color: hexToRgb('107b68') },
|
||||
{ stop: 0.8, color: hexToRgb('14c491') },
|
||||
{ stop: 1.0, color: hexToRgb('f2ffd2') },
|
||||
];
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
const fireflyCount = parseInt(process.argv[7] || String(DEFAULT_FIREFLY_COUNT), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node voxel-fireflies.js <device-ip> [port] [width] [height] [interval-ms] [firefly-count]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs) || Number.isNaN(fireflyCount)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, interval-ms, and firefly-count.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (fireflyCount <= 0) {
|
||||
console.error('Firefly count must be a positive integer.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
const frame = createFrame(width, height);
|
||||
const fireflies = createFireflies(fireflyCount, width, height);
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function createFireflies(count, matrixWidth, matrixHeight) {
|
||||
const list = [];
|
||||
for (let index = 0; index < count; ++index) {
|
||||
list.push(spawnFirefly(matrixWidth, matrixHeight));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function spawnFirefly(matrixWidth, matrixHeight) {
|
||||
return {
|
||||
x: Math.random() * (matrixWidth - 1),
|
||||
y: Math.random() * (matrixHeight - 1),
|
||||
targetX: Math.random() * (matrixWidth - 1),
|
||||
targetY: Math.random() * (matrixHeight - 1),
|
||||
glowPhase: Math.random() * Math.PI * 2,
|
||||
dwell: 1 + Math.random() * 2,
|
||||
};
|
||||
}
|
||||
|
||||
function updateFirefly(firefly, deltaSeconds) {
|
||||
const dx = firefly.targetX - firefly.x;
|
||||
const dy = firefly.targetY - firefly.y;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
|
||||
if (distance < 0.2) {
|
||||
firefly.dwell -= deltaSeconds;
|
||||
if (firefly.dwell <= 0) {
|
||||
firefly.targetX = Math.random() * (width - 1);
|
||||
firefly.targetY = Math.random() * (height - 1);
|
||||
firefly.dwell = 1 + Math.random() * 2;
|
||||
}
|
||||
} else {
|
||||
const speed = HOVER_SPEED * (0.8 + Math.random() * 0.4);
|
||||
firefly.x += (dx / distance) * speed * deltaSeconds;
|
||||
firefly.y += (dy / distance) * speed * deltaSeconds;
|
||||
}
|
||||
|
||||
firefly.glowPhase += deltaSeconds * GLOW_SPEED * (0.7 + Math.random() * 0.6);
|
||||
}
|
||||
|
||||
function drawFirefly(firefly) {
|
||||
const baseGlow = (Math.sin(firefly.glowPhase) + 1) * 0.5;
|
||||
const col = Math.round(firefly.x);
|
||||
const row = Math.round(firefly.y);
|
||||
|
||||
for (let dy = -1; dy <= 1; ++dy) {
|
||||
for (let dx = -1; dx <= 1; ++dx) {
|
||||
const sampleX = col + dx;
|
||||
const sampleY = row + dy;
|
||||
if (sampleX < 0 || sampleX >= width || sampleY < 0 || sampleY >= height) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const distance = Math.hypot(dx, dy);
|
||||
const falloff = clamp(1 - distance * 0.7, 0, 1);
|
||||
const intensity = baseGlow * falloff;
|
||||
if (intensity <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
frame[toIndex(sampleX, sampleY, width)] = samplePalette(paletteStops, intensity);
|
||||
if (distance === 0) {
|
||||
addHexColor(frame, toIndex(sampleX, sampleY, width), 'ffd966', intensity * 1.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function generateFrame() {
|
||||
fadeFrame(frame, TRAIL_DECAY);
|
||||
|
||||
fireflies.forEach((firefly) => {
|
||||
updateFirefly(firefly, frameTimeSeconds);
|
||||
drawFirefly(firefly);
|
||||
});
|
||||
|
||||
return frameToPayload(frame);
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming voxel fireflies to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms, fireflies=${fireflyCount})`
|
||||
);
|
||||
|
||||
108
test/pixelstream/wormhole-tunnel.js
Normal file
108
test/pixelstream/wormhole-tunnel.js
Normal file
@@ -0,0 +1,108 @@
|
||||
const dgram = require('dgram');
|
||||
|
||||
const {
|
||||
clamp,
|
||||
createFrame,
|
||||
frameToPayload,
|
||||
hexToRgb,
|
||||
samplePalette,
|
||||
toIndex,
|
||||
} = require('./shared-frame-utils');
|
||||
|
||||
const DEFAULT_PORT = 4210;
|
||||
const DEFAULT_WIDTH = 16;
|
||||
const DEFAULT_HEIGHT = 16;
|
||||
const DEFAULT_INTERVAL_MS = 60;
|
||||
const RING_DENSITY = 8;
|
||||
const RING_SPEED = 1.4;
|
||||
const RING_SHARPNESS = 7.5;
|
||||
const TWIST_INTENSITY = 2.2;
|
||||
const TWIST_SPEED = 0.9;
|
||||
const CORE_EXPONENT = 1.6;
|
||||
|
||||
const paletteStops = [
|
||||
{ stop: 0.0, color: hexToRgb('010005') },
|
||||
{ stop: 0.2, color: hexToRgb('07204f') },
|
||||
{ stop: 0.45, color: hexToRgb('124aa0') },
|
||||
{ stop: 0.7, color: hexToRgb('36a5ff') },
|
||||
{ stop: 0.87, color: hexToRgb('99e6ff') },
|
||||
{ stop: 1.0, color: hexToRgb('f1fbff') },
|
||||
];
|
||||
|
||||
const host = process.argv[2];
|
||||
const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10);
|
||||
const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10);
|
||||
const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10);
|
||||
const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10);
|
||||
|
||||
if (!host) {
|
||||
console.error('Usage: node wormhole-tunnel.js <device-ip> [port] [width] [height] [interval-ms]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs)) {
|
||||
console.error('Invalid numeric argument. Expected integers for port, width, height, and interval-ms.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
console.error('Matrix dimensions must be positive integers.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||
const frame = createFrame(width, height);
|
||||
let timeSeconds = 0;
|
||||
const frameTimeSeconds = intervalMs / 1000;
|
||||
|
||||
if (isBroadcast) {
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error.message);
|
||||
});
|
||||
|
||||
function generateFrame() {
|
||||
timeSeconds += frameTimeSeconds;
|
||||
|
||||
const cx = (width - 1) / 2;
|
||||
const cy = (height - 1) / 2;
|
||||
const radiusNorm = Math.hypot(cx, cy) || 1;
|
||||
|
||||
for (let row = 0; row < height; ++row) {
|
||||
for (let col = 0; col < width; ++col) {
|
||||
const dx = col - cx;
|
||||
const dy = row - cy;
|
||||
const radius = Math.hypot(dx, dy) / radiusNorm;
|
||||
const angle = Math.atan2(dy, dx);
|
||||
|
||||
const radialPhase = radius * RING_DENSITY - timeSeconds * RING_SPEED;
|
||||
const ring = Math.exp(-Math.pow(Math.sin(radialPhase * Math.PI), 2) * RING_SHARPNESS);
|
||||
|
||||
const twist = Math.sin(angle * TWIST_INTENSITY + timeSeconds * TWIST_SPEED) * 0.35 + 0.65;
|
||||
const depth = Math.pow(clamp(1 - radius, 0, 1), CORE_EXPONENT);
|
||||
|
||||
const value = clamp(ring * 0.6 + depth * 0.3 + twist * 0.1, 0, 1);
|
||||
frame[toIndex(col, row, width)] = samplePalette(paletteStops, value);
|
||||
}
|
||||
}
|
||||
|
||||
return frameToPayload(frame);
|
||||
}
|
||||
|
||||
function sendFrame() {
|
||||
const payload = generateFrame();
|
||||
const message = Buffer.from(payload, 'utf8');
|
||||
socket.send(message, port, host);
|
||||
}
|
||||
|
||||
setInterval(sendFrame, intervalMs);
|
||||
|
||||
console.log(
|
||||
`Streaming wormhole tunnel to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms)`
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user