Compare commits
3 Commits
52f9098c1b
...
refactorin
| Author | SHA1 | Date | |
|---|---|---|---|
| 025ac7b810 | |||
| a9f56c1279 | |||
| 594b5e3af6 |
50
README.md
50
README.md
@@ -9,8 +9,6 @@ SPORE is a cluster engine for ESP8266 microcontrollers that provides automatic n
|
|||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [Supported Hardware](#supported-hardware)
|
- [Supported Hardware](#supported-hardware)
|
||||||
- [Architecture](#architecture)
|
- [Architecture](#architecture)
|
||||||
- [Cluster Broadcast](#cluster-broadcast)
|
|
||||||
- [Streaming API](#streaming-api)
|
|
||||||
- [API Reference](#api-reference)
|
- [API Reference](#api-reference)
|
||||||
- [Configuration](#configuration)
|
- [Configuration](#configuration)
|
||||||
- [Development](#development)
|
- [Development](#development)
|
||||||
@@ -28,8 +26,6 @@ SPORE is a cluster engine for ESP8266 microcontrollers that provides automatic n
|
|||||||
- **Service Registry**: Dynamic API endpoint discovery and registration
|
- **Service Registry**: Dynamic API endpoint discovery and registration
|
||||||
- **Health Monitoring**: Real-time node status tracking with resource monitoring
|
- **Health Monitoring**: Real-time node status tracking with resource monitoring
|
||||||
- **Event System**: Local and cluster-wide event publishing/subscription
|
- **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
|
- **Over-The-Air Updates**: Seamless firmware updates across the cluster
|
||||||
- **REST API**: HTTP-based cluster management and monitoring
|
- **REST API**: HTTP-based cluster management and monitoring
|
||||||
- **Capability Discovery**: Automatic API endpoint and service capability detection
|
- **Capability Discovery**: Automatic API endpoint and service capability detection
|
||||||
@@ -107,52 +103,6 @@ void setup() {
|
|||||||
|
|
||||||
**Examples:** See [`examples/base/`](./examples/base/) for basic usage and [`examples/relay/`](./examples/relay/) for custom service integration.
|
**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
|
## 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.
|
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,18 +15,12 @@ The SPORE system provides a comprehensive RESTful API for monitoring and control
|
|||||||
|
|
||||||
| Endpoint | Method | Description | Response |
|
| Endpoint | Method | Description | Response |
|
||||||
|----------|--------|-------------|----------|
|
|----------|--------|-------------|----------|
|
||||||
| `/api/node/status` | GET | System resource information | System metrics |
|
| `/api/node/status` | GET | System resource information and API endpoint registry | System metrics and API catalog |
|
||||||
| `/api/node/endpoints` | GET | API endpoints and parameters | Detailed endpoint specifications |
|
| `/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/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/update` | POST | Handle firmware updates via OTA | Update progress and status |
|
||||||
| `/api/node/restart` | POST | Trigger system restart | Restart confirmation |
|
| `/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
|
### Network Management API
|
||||||
|
|
||||||
| Endpoint | Method | Description | Response |
|
| Endpoint | Method | Description | Response |
|
||||||
@@ -146,7 +140,7 @@ Controls the execution state of individual tasks. Supports enabling, disabling,
|
|||||||
|
|
||||||
#### GET /api/node/status
|
#### GET /api/node/status
|
||||||
|
|
||||||
Returns comprehensive system resource information including memory usage and chip details. For a list of available API endpoints, use `/api/node/endpoints`.
|
Returns comprehensive system resource information including memory usage, chip details, and a registry of all available API endpoints.
|
||||||
|
|
||||||
**Response Fields:**
|
**Response Fields:**
|
||||||
- `freeHeap`: Available RAM in bytes
|
- `freeHeap`: Available RAM in bytes
|
||||||
@@ -174,7 +168,7 @@ Returns comprehensive system resource information including memory usage and chi
|
|||||||
|
|
||||||
#### GET /api/node/endpoints
|
#### GET /api/node/endpoints
|
||||||
|
|
||||||
Returns detailed information about all available API endpoints, including their parameters, types, and validation rules. Methods are returned as strings (e.g., "GET", "POST").
|
Returns detailed information about all available API endpoints, including their parameters, types, and validation rules.
|
||||||
|
|
||||||
**Response Fields:**
|
**Response Fields:**
|
||||||
- `endpoints[]`: Array of endpoint capability objects
|
- `endpoints[]`: Array of endpoint capability objects
|
||||||
@@ -242,54 +236,6 @@ 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.
|
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
|
### Network Management
|
||||||
|
|
||||||
#### GET /api/network/status
|
#### GET /api/network/status
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ The system architecture consists of several key components working together:
|
|||||||
- **Service Registry**: Track available services across the cluster
|
- **Service Registry**: Track available services across the cluster
|
||||||
|
|
||||||
### Task Scheduler
|
### Task Scheduler
|
||||||
- **Cooperative Multitasking**: Background task management system (`TaskManager`)
|
- **Cooperative Multitasking**: Background task management system
|
||||||
- **Task Lifecycle Management**: Enable/disable tasks and set intervals at runtime
|
- **Task Lifecycle Management**: Automatic task execution and monitoring
|
||||||
- **Execution Model**: Tasks run in `Spore::loop()` when their interval elapses
|
- **Resource Optimization**: Efficient task scheduling and execution
|
||||||
|
|
||||||
### Node Context
|
### Node Context
|
||||||
- **Central Context**: Shared resources and configuration
|
- **Central Context**: Shared resources and configuration
|
||||||
@@ -40,75 +40,27 @@ The cluster uses a UDP-based discovery protocol for automatic node detection:
|
|||||||
|
|
||||||
### Discovery Process
|
### Discovery Process
|
||||||
|
|
||||||
1. **Discovery Broadcast**: Nodes periodically send UDP packets on port `udp_port` (default 4210)
|
1. **Discovery Broadcast**: Nodes periodically send UDP packets on port 4210
|
||||||
2. **Response Handling**: Nodes respond with `CLUSTER_RESPONSE:<hostname>`
|
2. **Response Handling**: Nodes respond with their hostname and IP address
|
||||||
3. **Member Management**: Discovered nodes are added/updated in the cluster
|
3. **Member Management**: Discovered nodes are automatically added to the cluster
|
||||||
4. **Node Info via UDP**: Heartbeat triggers peers to send `CLUSTER_NODE_INFO:<hostname>:<json>`
|
4. **Health Monitoring**: Continuous status checking via HTTP API calls
|
||||||
|
|
||||||
### Protocol Details
|
### Protocol Details
|
||||||
|
|
||||||
- **UDP Port**: 4210 (configurable via `Config.udp_port`)
|
- **UDP Port**: 4210 (configurable)
|
||||||
- **Discovery Message**: `CLUSTER_DISCOVERY`
|
- **Discovery Message**: `CLUSTER_DISCOVERY`
|
||||||
- **Response Message**: `CLUSTER_RESPONSE`
|
- **Response Message**: `CLUSTER_RESPONSE`
|
||||||
- **Heartbeat Message**: `CLUSTER_HEARTBEAT`
|
|
||||||
- **Node Info Message**: `CLUSTER_NODE_INFO:<hostname>:<json>`
|
|
||||||
- **Broadcast Address**: 255.255.255.255
|
- **Broadcast Address**: 255.255.255.255
|
||||||
- **Discovery Interval**: `Config.discovery_interval_ms` (default 1000 ms)
|
- **Discovery Interval**: 1 second (configurable)
|
||||||
- **Listen Interval**: `Config.cluster_listen_interval_ms` (default 10 ms)
|
- **Listen Interval**: 100ms (configurable)
|
||||||
- **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
|
### Node Status Categories
|
||||||
|
|
||||||
Nodes are automatically categorized by their activity:
|
Nodes are automatically categorized by their activity:
|
||||||
|
|
||||||
- **ACTIVE**: lastSeen < `node_inactive_threshold_ms` (default 10s)
|
- **ACTIVE**: Responding within 10 seconds
|
||||||
- **INACTIVE**: < `node_dead_threshold_ms` (default 120s)
|
- **INACTIVE**: No response for 10-60 seconds
|
||||||
- **DEAD**: ≥ `node_dead_threshold_ms`
|
- **DEAD**: No response for over 60 seconds
|
||||||
|
|
||||||
## Task Scheduling System
|
## Task Scheduling System
|
||||||
|
|
||||||
@@ -116,14 +68,14 @@ The system runs several background tasks at different intervals:
|
|||||||
|
|
||||||
### Core System Tasks
|
### Core System Tasks
|
||||||
|
|
||||||
| Task | Interval (default) | Purpose |
|
| Task | Interval | Purpose |
|
||||||
|------|--------------------|---------|
|
|------|----------|---------|
|
||||||
| `cluster_discovery` | 1000 ms | Send UDP discovery packets |
|
| **Discovery Send** | 1 second | Send UDP discovery packets |
|
||||||
| `cluster_listen` | 10 ms | Listen for discovery/heartbeat/node-info |
|
| **Discovery Listen** | 100ms | Listen for discovery responses |
|
||||||
| `status_update` | 1000 ms | Update node status categories, purge dead |
|
| **Status Updates** | 1 second | Monitor cluster member health |
|
||||||
| `heartbeat` | 5000 ms | Broadcast heartbeat and update local resources |
|
| **Heartbeat** | 2 seconds | Maintain cluster connectivity |
|
||||||
| `cluster_update_members_info` | 10000 ms | Reserved; no-op (info via UDP) |
|
| **Member Info** | 10 seconds | Update detailed node information |
|
||||||
| `print_members` | 5000 ms | Log current member list |
|
| **Debug Output** | 5 seconds | Print cluster status |
|
||||||
|
|
||||||
### Task Management Features
|
### Task Management Features
|
||||||
|
|
||||||
@@ -160,7 +112,10 @@ ctx.fire("cluster_updated", &clusterData);
|
|||||||
|
|
||||||
### Available Events
|
### Available Events
|
||||||
|
|
||||||
- **`node_discovered`**: New node added or local node refreshed
|
- **`node_discovered`**: New node added to cluster
|
||||||
|
- **`cluster_updated`**: Cluster membership changed
|
||||||
|
- **`resource_update`**: Node resources updated
|
||||||
|
- **`health_check`**: Node health status changed
|
||||||
|
|
||||||
## Resource Monitoring
|
## Resource Monitoring
|
||||||
|
|
||||||
@@ -200,8 +155,10 @@ The system includes automatic WiFi fallback for robust operation:
|
|||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
- **Hostname**: Derived from MAC (`esp-<mac>`) and assigned to `ctx.hostname`
|
- **SSID Format**: `SPORE_<MAC_LAST_4>`
|
||||||
- **AP Mode**: If STA connection fails, device switches to AP mode with configured SSID/password
|
- **Password**: Configurable fallback password
|
||||||
|
- **IP Range**: 192.168.4.x subnet
|
||||||
|
- **Gateway**: 192.168.4.1
|
||||||
|
|
||||||
## Cluster Topology
|
## Cluster Topology
|
||||||
|
|
||||||
@@ -213,30 +170,32 @@ The system includes automatic WiFi fallback for robust operation:
|
|||||||
|
|
||||||
### Network Architecture
|
### Network Architecture
|
||||||
|
|
||||||
- UDP broadcast-based discovery and heartbeats on local subnet
|
- **Mesh-like Structure**: Nodes can communicate with each other
|
||||||
- Optional HTTP polling (disabled by default; node info exchanged via UDP)
|
- **Dynamic Routing**: Automatic path discovery between nodes
|
||||||
|
- **Load Distribution**: Tasks distributed across available nodes
|
||||||
|
- **Fault Tolerance**: Automatic failover and recovery
|
||||||
|
|
||||||
## Data Flow
|
## Data Flow
|
||||||
|
|
||||||
### Node Discovery
|
### Node Discovery
|
||||||
1. **UDP Broadcast**: Nodes broadcast discovery packets on port 4210
|
1. **UDP Broadcast**: Nodes broadcast discovery packets on port 4210
|
||||||
2. **UDP Response**: Receiving nodes respond with hostname
|
2. **UDP Response**: Receiving nodes responds with hostname
|
||||||
3. **Registration**: Discovered nodes are added to local cluster member list
|
3. **Registration**: Discovered nodes are added to local cluster member list
|
||||||
|
|
||||||
### Health Monitoring
|
### Health Monitoring
|
||||||
1. **Periodic Checks**: Cluster manager updates node status categories
|
1. **Periodic Checks**: Cluster manager polls member nodes every 1 second
|
||||||
2. **Status Collection**: Each node updates resources via UDP node-info messages
|
2. **Status Collection**: Each node returns resource usage and health metrics
|
||||||
|
|
||||||
### Task Management
|
### Task Management
|
||||||
1. **Scheduling**: `TaskManager` executes registered tasks at configured intervals
|
1. **Scheduling**: TaskScheduler executes registered tasks at configured intervals
|
||||||
2. **Execution**: Tasks run cooperatively in the main loop without preemption
|
2. **Execution**: Tasks run cooperatively, yielding control to other tasks
|
||||||
3. **Monitoring**: Task status is exposed via REST (`/api/tasks/status`)
|
3. **Monitoring**: Task status and results are exposed via REST API endpoints
|
||||||
|
|
||||||
## Performance Characteristics
|
## Performance Characteristics
|
||||||
|
|
||||||
### Memory Usage
|
### Memory Usage
|
||||||
|
|
||||||
- **Base System**: ~15-20KB RAM (device dependent)
|
- **Base System**: ~15-20KB RAM
|
||||||
- **Per Task**: ~100-200 bytes per task
|
- **Per Task**: ~100-200 bytes per task
|
||||||
- **Cluster Members**: ~50-100 bytes per member
|
- **Cluster Members**: ~50-100 bytes per member
|
||||||
- **API Endpoints**: ~20-30 bytes per endpoint
|
- **API Endpoints**: ~20-30 bytes per endpoint
|
||||||
@@ -260,7 +219,7 @@ The system includes automatic WiFi fallback for robust operation:
|
|||||||
### Current Implementation
|
### Current Implementation
|
||||||
|
|
||||||
- **Network Access**: Local network only (no internet exposure)
|
- **Network Access**: Local network only (no internet exposure)
|
||||||
- **Authentication**: None currently implemented; LAN-only access assumed
|
- **Authentication**: None currently implemented
|
||||||
- **Data Validation**: Basic input validation
|
- **Data Validation**: Basic input validation
|
||||||
- **Resource Limits**: Memory and processing constraints
|
- **Resource Limits**: Memory and processing constraints
|
||||||
|
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
## 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,27 +20,19 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
spore/
|
spore/
|
||||||
├── src/ # Source code (framework under src/spore)
|
├── src/ # Source code
|
||||||
│ └── spore/
|
│ ├── main.cpp # Main application entry point
|
||||||
│ ├── Spore.cpp # Framework lifecycle (setup/begin/loop)
|
│ ├── ApiServer.cpp # HTTP API server implementation
|
||||||
│ ├── core/ # Core components
|
│ ├── ClusterManager.cpp # Cluster management logic
|
||||||
│ │ ├── ApiServer.cpp # HTTP API server implementation
|
│ ├── NetworkManager.cpp # WiFi and network handling
|
||||||
│ │ ├── ClusterManager.cpp # Cluster management logic
|
│ ├── TaskManager.cpp # Background task management
|
||||||
│ │ ├── NetworkManager.cpp # WiFi and network handling
|
│ └── NodeContext.cpp # Central context and events
|
||||||
│ │ ├── TaskManager.cpp # Background task management
|
|
||||||
│ │ └── NodeContext.cpp # Central context and events
|
|
||||||
│ ├── services/ # Built-in services
|
|
||||||
│ │ ├── NodeService.cpp
|
|
||||||
│ │ ├── NetworkService.cpp
|
|
||||||
│ │ ├── ClusterService.cpp
|
|
||||||
│ │ ├── TaskService.cpp
|
|
||||||
│ │ ├── StaticFileService.cpp
|
|
||||||
│ │ └── MonitoringService.cpp
|
|
||||||
│ └── types/ # Shared types
|
|
||||||
├── include/ # Header files
|
├── include/ # Header files
|
||||||
├── examples/ # Example apps per env (base, relay, neopattern)
|
├── lib/ # Library files
|
||||||
├── docs/ # Documentation
|
├── docs/ # Documentation
|
||||||
├── api/ # OpenAPI specification
|
├── api/ # OpenAPI specification
|
||||||
|
├── examples/ # Example code
|
||||||
|
├── test/ # Test files
|
||||||
├── platformio.ini # PlatformIO configuration
|
├── platformio.ini # PlatformIO configuration
|
||||||
└── ctl.sh # Build and deployment scripts
|
└── ctl.sh # Build and deployment scripts
|
||||||
```
|
```
|
||||||
@@ -49,70 +41,36 @@ spore/
|
|||||||
|
|
||||||
### Framework and Board
|
### Framework and Board
|
||||||
|
|
||||||
The project uses PlatformIO with the following configuration (excerpt):
|
The project uses PlatformIO with the following configuration:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[platformio]
|
[env:esp01_1m]
|
||||||
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
|
platform = platformio/espressif8266@^4.2.1
|
||||||
board = esp01_1m
|
board = esp01_1m
|
||||||
framework = arduino
|
framework = arduino
|
||||||
upload_speed = 115200
|
upload_speed = 115200
|
||||||
monitor_speed = 115200
|
flash_mode = dout
|
||||||
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
|
### Dependencies
|
||||||
|
|
||||||
The project requires the following libraries (resolved via PlatformIO):
|
The project requires the following libraries:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
lib_deps =
|
lib_deps =
|
||||||
esp32async/ESPAsyncWebServer@^3.8.0
|
esp32async/ESPAsyncWebServer@^3.8.0
|
||||||
bblanchon/ArduinoJson@^7.4.2
|
bblanchon/ArduinoJson@^7.4.2
|
||||||
|
arkhipenko/TaskScheduler@^3.8.5
|
||||||
|
ESP8266HTTPClient@1.2
|
||||||
|
ESP8266WiFi@1.0
|
||||||
```
|
```
|
||||||
|
|
||||||
### Filesystem, Linker Scripts, and Flash Layout
|
### Filesystem, Linker Scripts, and Flash Layout
|
||||||
@@ -145,6 +103,7 @@ Notes:
|
|||||||
- If you need a different FS size, select an appropriate ldscript variant and keep `board_build.filesystem = littlefs`.
|
- 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.
|
- 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
|
## Building
|
||||||
|
|
||||||
### Basic Build Commands
|
### Basic Build Commands
|
||||||
@@ -349,7 +308,7 @@ export API_NODE=192.168.1.100
|
|||||||
Key configuration files:
|
Key configuration files:
|
||||||
|
|
||||||
- **`platformio.ini`**: Build and upload configuration
|
- **`platformio.ini`**: Build and upload configuration
|
||||||
- **`src/spore/types/Config.cpp`**: Default runtime configuration
|
- **`src/Config.cpp`**: Application configuration
|
||||||
- **`.env`**: Environment variables
|
- **`.env`**: Environment variables
|
||||||
- **`ctl.sh`**: Build and deployment scripts
|
- **`ctl.sh`**: Build and deployment scripts
|
||||||
|
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
# 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,8 +15,15 @@ Complete API reference with detailed endpoint documentation, examples, and integ
|
|||||||
- Task management workflows
|
- Task management workflows
|
||||||
- Cluster monitoring examples
|
- Cluster monitoring examples
|
||||||
|
|
||||||
### 📖 [MonitoringService.md](./MonitoringService.md)
|
### 📖 [TaskManager.md](./TaskManager.md)
|
||||||
System resource monitoring API for CPU, memory, filesystem, and uptime.
|
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
|
||||||
|
|
||||||
### 📖 [TaskManagement.md](./TaskManagement.md)
|
### 📖 [TaskManagement.md](./TaskManagement.md)
|
||||||
Complete guide to the task management system with examples and best practices.
|
Complete guide to the task management system with examples and best practices.
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
## 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.
|
|
||||||
|
|
||||||
@@ -319,18 +319,18 @@ curl -X POST http://192.168.1.100/api/tasks/control \
|
|||||||
### Before (with wrapper functions):
|
### Before (with wrapper functions):
|
||||||
```cpp
|
```cpp
|
||||||
void discoverySendTask() { cluster.sendDiscovery(); }
|
void discoverySendTask() { cluster.sendDiscovery(); }
|
||||||
void clusterListenTask() { cluster.listen(); }
|
void discoveryListenTask() { cluster.listenForDiscovery(); }
|
||||||
|
|
||||||
taskManager.registerTask("discovery_send", interval, discoverySendTask);
|
taskManager.registerTask("discovery_send", interval, discoverySendTask);
|
||||||
taskManager.registerTask("cluster_listen", interval, clusterListenTask);
|
taskManager.registerTask("discovery_listen", interval, discoveryListenTask);
|
||||||
```
|
```
|
||||||
|
|
||||||
### After (with std::bind):
|
### After (with std::bind):
|
||||||
```cpp
|
```cpp
|
||||||
taskManager.registerTask("discovery_send", interval,
|
taskManager.registerTask("discovery_send", interval,
|
||||||
std::bind(&ClusterManager::sendDiscovery, &cluster));
|
std::bind(&ClusterManager::sendDiscovery, &cluster));
|
||||||
taskManager.registerTask("cluster_listen", interval,
|
taskManager.registerTask("discovery_listen", interval,
|
||||||
std::bind(&ClusterManager::listen, &cluster));
|
std::bind(&ClusterManager::listenForDiscovery, &cluster));
|
||||||
```
|
```
|
||||||
|
|
||||||
## Compatibility
|
## Compatibility
|
||||||
|
|||||||
@@ -1,249 +0,0 @@
|
|||||||
#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");
|
|
||||||
}
|
|
||||||
registerTasks();
|
|
||||||
}
|
|
||||||
|
|
||||||
void MultiMatrixService::registerEndpoints(ApiServer& api) {
|
|
||||||
api.addEndpoint(API_STATUS_ENDPOINT, HTTP_GET,
|
|
||||||
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
|
|
||||||
std::vector<ParamSpec>{});
|
|
||||||
|
|
||||||
api.addEndpoint(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() {
|
|
||||||
m_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);
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
#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;
|
|
||||||
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 registerTasks();
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
@@ -1,508 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
#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.addService(audioService);
|
|
||||||
|
|
||||||
spore.begin();
|
|
||||||
|
|
||||||
LOG_INFO("MultiMatrix", "Setup complete");
|
|
||||||
}
|
|
||||||
|
|
||||||
void loop() {
|
|
||||||
spore.loop();
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
#include "NeoPatternService.h"
|
#include "NeoPatternService.h"
|
||||||
#include "spore/core/ApiServer.h"
|
#include "spore/core/ApiServer.h"
|
||||||
#include "spore/util/Logging.h"
|
#include "spore/util/Logging.h"
|
||||||
#include "spore/internal/Globals.h"
|
|
||||||
#include <ArduinoJson.h>
|
#include <ArduinoJson.h>
|
||||||
#include <ESP8266WiFi.h>
|
|
||||||
|
|
||||||
NeoPatternService::NeoPatternService(NodeContext& ctx, TaskManager& taskMgr, const NeoPixelConfig& config)
|
NeoPatternService::NeoPatternService(TaskManager& taskMgr, const NeoPixelConfig& config)
|
||||||
: taskManager(taskMgr),
|
: taskManager(taskMgr),
|
||||||
ctx(ctx),
|
|
||||||
config(config),
|
config(config),
|
||||||
activePattern(NeoPatternType::RAINBOW_CYCLE),
|
activePattern(NeoPatternType::RAINBOW_CYCLE),
|
||||||
direction(NeoDirection::FORWARD),
|
direction(NeoDirection::FORWARD),
|
||||||
@@ -35,7 +32,6 @@ NeoPatternService::NeoPatternService(NodeContext& ctx, TaskManager& taskMgr, con
|
|||||||
|
|
||||||
registerPatterns();
|
registerPatterns();
|
||||||
registerTasks();
|
registerTasks();
|
||||||
registerEventHandlers();
|
|
||||||
initialized = true;
|
initialized = true;
|
||||||
|
|
||||||
LOG_INFO("NeoPattern", "Service initialized");
|
LOG_INFO("NeoPattern", "Service initialized");
|
||||||
@@ -68,8 +64,7 @@ void NeoPatternService::registerEndpoints(ApiServer& api) {
|
|||||||
ParamSpec{String("brightness"), false, String("body"), String("numberRange"), {}, String("80")},
|
ParamSpec{String("brightness"), false, String("body"), String("numberRange"), {}, String("80")},
|
||||||
ParamSpec{String("total_steps"), false, String("body"), String("numberRange"), {}, String("16")},
|
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("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
|
// State endpoint for complex state updates
|
||||||
@@ -124,49 +119,61 @@ void NeoPatternService::handlePatternsRequest(AsyncWebServerRequest* request) {
|
|||||||
|
|
||||||
void NeoPatternService::handleControlRequest(AsyncWebServerRequest* request) {
|
void NeoPatternService::handleControlRequest(AsyncWebServerRequest* request) {
|
||||||
bool updated = false;
|
bool updated = false;
|
||||||
bool broadcast = false;
|
|
||||||
|
|
||||||
if (request->hasParam("broadcast", true)) {
|
if (request->hasParam("pattern", true)) {
|
||||||
String b = request->getParam("broadcast", true)->value();
|
String name = request->getParam("pattern", true)->value();
|
||||||
broadcast = b.equalsIgnoreCase("true") || b == "1";
|
if (isValidPattern(name)) {
|
||||||
|
setPatternByName(name);
|
||||||
|
updated = true;
|
||||||
|
} else {
|
||||||
|
// Invalid pattern name - could add error handling here
|
||||||
|
LOG_WARN("NeoPattern", "Invalid pattern name: " + name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build JSON payload from provided params (single source of truth)
|
if (request->hasParam("color", true)) {
|
||||||
JsonDocument payload;
|
String colorStr = request->getParam("color", true)->value();
|
||||||
bool any = false;
|
uint32_t color = parseColor(colorStr);
|
||||||
if (request->hasParam("pattern", true)) { payload["pattern"] = request->getParam("pattern", true)->value(); any = true; }
|
setColor(color);
|
||||||
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;
|
updated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast to peers if requested (delegate to core broadcast handler)
|
if (request->hasParam("color2", true)) {
|
||||||
if (broadcast && any) {
|
String colorStr = request->getParam("color2", true)->value();
|
||||||
JsonDocument eventDoc;
|
uint32_t color = parseColor(colorStr);
|
||||||
eventDoc["event"] = "api/neopattern";
|
setColor2(color);
|
||||||
eventDoc["data"] = payloadStr; // data is JSON string
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
String eventJson;
|
if (request->hasParam("brightness", true)) {
|
||||||
serializeJson(eventDoc, eventJson);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
LOG_INFO("NeoPattern", String("Submitting cluster/broadcast for api/neopattern payloadLen=") + String(payloadStr.length()));
|
if (request->hasParam("total_steps", true)) {
|
||||||
std::string ev = "cluster/broadcast";
|
int steps = request->getParam("total_steps", true)->value().toInt();
|
||||||
String eventStr = eventJson;
|
if (steps > 0) {
|
||||||
ctx.fire(ev, &eventStr);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return current state
|
// Return current state
|
||||||
@@ -185,139 +192,6 @@ void NeoPatternService::handleControlRequest(AsyncWebServerRequest* request) {
|
|||||||
serializeJson(resp, json);
|
serializeJson(resp, json);
|
||||||
request->send(200, "application/json", 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) {
|
void NeoPatternService::handleStateRequest(AsyncWebServerRequest* request) {
|
||||||
if (request->contentType() != "application/json") {
|
if (request->contentType() != "application/json") {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include "spore/Service.h"
|
#include "spore/Service.h"
|
||||||
#include "spore/core/TaskManager.h"
|
#include "spore/core/TaskManager.h"
|
||||||
#include "spore/core/NodeContext.h"
|
|
||||||
#include "NeoPattern.h"
|
#include "NeoPattern.h"
|
||||||
#include "NeoPatternState.h"
|
#include "NeoPatternState.h"
|
||||||
#include "NeoPixelConfig.h"
|
#include "NeoPixelConfig.h"
|
||||||
@@ -26,7 +25,7 @@ public:
|
|||||||
REVERSE
|
REVERSE
|
||||||
};
|
};
|
||||||
|
|
||||||
NeoPatternService(NodeContext& ctx, TaskManager& taskMgr, const NeoPixelConfig& config);
|
NeoPatternService(TaskManager& taskMgr, const NeoPixelConfig& config);
|
||||||
~NeoPatternService();
|
~NeoPatternService();
|
||||||
|
|
||||||
void registerEndpoints(ApiServer& api) override;
|
void registerEndpoints(ApiServer& api) override;
|
||||||
@@ -50,8 +49,6 @@ private:
|
|||||||
void registerTasks();
|
void registerTasks();
|
||||||
void registerPatterns();
|
void registerPatterns();
|
||||||
void update();
|
void update();
|
||||||
void registerEventHandlers();
|
|
||||||
bool applyControlParams(const JsonObject& obj);
|
|
||||||
|
|
||||||
// Pattern updaters
|
// Pattern updaters
|
||||||
void updateRainbowCycle();
|
void updateRainbowCycle();
|
||||||
@@ -83,7 +80,6 @@ private:
|
|||||||
String getPatternDescription(const String& name) const;
|
String getPatternDescription(const String& name) const;
|
||||||
|
|
||||||
TaskManager& taskManager;
|
TaskManager& taskManager;
|
||||||
NodeContext& ctx;
|
|
||||||
NeoPattern* neoPattern;
|
NeoPattern* neoPattern;
|
||||||
NeoPixelConfig config;
|
NeoPixelConfig config;
|
||||||
NeoPatternState currentState;
|
NeoPatternState currentState;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifndef NEOPIXEL_LENGTH
|
#ifndef NEOPIXEL_LENGTH
|
||||||
#define NEOPIXEL_LENGTH 16
|
#define NEOPIXEL_LENGTH 8
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifndef NEOPIXEL_BRIGHTNESS
|
#ifndef NEOPIXEL_BRIGHTNESS
|
||||||
@@ -45,7 +45,7 @@ void setup() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Create and add custom service
|
// Create and add custom service
|
||||||
neoPatternService = new NeoPatternService(spore.getContext(), spore.getTaskManager(), config);
|
neoPatternService = new NeoPatternService(spore.getTaskManager(), config);
|
||||||
spore.addService(neoPatternService);
|
spore.addService(neoPatternService);
|
||||||
|
|
||||||
// Start the API server and complete initialization
|
// Start the API server and complete initialization
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
#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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
#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;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# 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`.
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
#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();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include <ArduinoJson.h>
|
#include <ArduinoJson.h>
|
||||||
#include <ESPAsyncWebServer.h>
|
#include <ESPAsyncWebServer.h>
|
||||||
#include <AsyncWebSocket.h>
|
|
||||||
#include <Updater.h>
|
#include <Updater.h>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -12,6 +11,7 @@
|
|||||||
#include "spore/types/NodeInfo.h"
|
#include "spore/types/NodeInfo.h"
|
||||||
#include "spore/core/TaskManager.h"
|
#include "spore/core/TaskManager.h"
|
||||||
#include "spore/types/ApiTypes.h"
|
#include "spore/types/ApiTypes.h"
|
||||||
|
#include "spore/util/Logging.h"
|
||||||
|
|
||||||
class Service; // Forward declaration
|
class Service; // Forward declaration
|
||||||
|
|
||||||
@@ -20,15 +20,17 @@ public:
|
|||||||
ApiServer(NodeContext& ctx, TaskManager& taskMgr, uint16_t port = 80);
|
ApiServer(NodeContext& ctx, TaskManager& taskMgr, uint16_t port = 80);
|
||||||
void begin();
|
void begin();
|
||||||
void addService(Service& service);
|
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 addEndpoint(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 String& serviceName = "unknown");
|
||||||
|
|
||||||
void addEndpoint(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 addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||||
std::function<void(AsyncWebServerRequest*, const String&, size_t, uint8_t*, size_t, bool)> uploadHandler,
|
std::function<void(AsyncWebServerRequest*, const String&, size_t, uint8_t*, size_t, bool)> uploadHandler,
|
||||||
const std::vector<ParamSpec>& params);
|
const String& serviceName = "unknown");
|
||||||
|
|
||||||
|
void addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||||
|
const std::vector<ParamSpec>& params, const String& serviceName = "unknown");
|
||||||
|
void addEndpoint(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, const String& serviceName = "unknown");
|
||||||
|
|
||||||
// Static file serving
|
// Static file serving
|
||||||
void serveStatic(const String& uri, fs::FS& fs, const String& path, const String& cache_header = "");
|
void serveStatic(const String& uri, fs::FS& fs, const String& path, const String& cache_header = "");
|
||||||
@@ -40,18 +42,13 @@ public:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
AsyncWebServer server;
|
AsyncWebServer server;
|
||||||
AsyncWebSocket ws{ "/ws" };
|
|
||||||
NodeContext& ctx;
|
NodeContext& ctx;
|
||||||
TaskManager& taskManager;
|
TaskManager& taskManager;
|
||||||
std::vector<std::reference_wrapper<Service>> services;
|
std::vector<std::reference_wrapper<Service>> services;
|
||||||
std::vector<EndpointInfo> endpoints; // Single source of truth for endpoints
|
std::vector<EndpointInfo> endpoints; // Single source of truth for endpoints
|
||||||
std::vector<AsyncWebSocketClient*> wsClients;
|
|
||||||
|
|
||||||
// Internal helpers
|
// Internal helpers
|
||||||
void registerEndpoint(const String& uri, int method,
|
void registerEndpoint(const String& uri, int method,
|
||||||
const std::vector<ParamSpec>& params,
|
const std::vector<ParamSpec>& params,
|
||||||
const String& serviceName);
|
const String& serviceName);
|
||||||
|
|
||||||
// WebSocket helpers
|
|
||||||
void setupWebSocket();
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,15 +7,13 @@
|
|||||||
#include <ArduinoJson.h>
|
#include <ArduinoJson.h>
|
||||||
#include <ESP8266HTTPClient.h>
|
#include <ESP8266HTTPClient.h>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <vector>
|
|
||||||
#include <functional>
|
|
||||||
|
|
||||||
class ClusterManager {
|
class ClusterManager {
|
||||||
public:
|
public:
|
||||||
ClusterManager(NodeContext& ctx, TaskManager& taskMgr);
|
ClusterManager(NodeContext& ctx, TaskManager& taskMgr);
|
||||||
void registerTasks();
|
void registerTasks();
|
||||||
void sendDiscovery();
|
void sendDiscovery();
|
||||||
void listen();
|
void listenForDiscovery();
|
||||||
void addOrUpdateNode(const String& nodeHost, IPAddress nodeIP);
|
void addOrUpdateNode(const String& nodeHost, IPAddress nodeIP);
|
||||||
void updateAllNodeStatuses();
|
void updateAllNodeStatuses();
|
||||||
void removeDeadNodes();
|
void removeDeadNodes();
|
||||||
@@ -28,25 +26,4 @@ public:
|
|||||||
private:
|
private:
|
||||||
NodeContext& ctx;
|
NodeContext& ctx;
|
||||||
TaskManager& taskManager;
|
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,14 +19,11 @@ public:
|
|||||||
IPAddress localIP;
|
IPAddress localIP;
|
||||||
NodeInfo self;
|
NodeInfo self;
|
||||||
std::map<String, NodeInfo>* memberList;
|
std::map<String, NodeInfo>* memberList;
|
||||||
::Config config;
|
Config config;
|
||||||
|
|
||||||
using EventCallback = std::function<void(void*)>;
|
using EventCallback = std::function<void(void*)>;
|
||||||
std::map<std::string, std::vector<EventCallback>> eventRegistry;
|
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 on(const std::string& event, EventCallback cb);
|
||||||
void fire(const std::string& event, void* data);
|
void fire(const std::string& event, void* data);
|
||||||
void onAny(AnyEventCallback cb);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,13 +7,8 @@
|
|||||||
namespace ClusterProtocol {
|
namespace ClusterProtocol {
|
||||||
constexpr const char* DISCOVERY_MSG = "CLUSTER_DISCOVERY";
|
constexpr const char* DISCOVERY_MSG = "CLUSTER_DISCOVERY";
|
||||||
constexpr const char* RESPONSE_MSG = "CLUSTER_RESPONSE";
|
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 uint16_t UDP_PORT = 4210;
|
||||||
// Increased buffer to accommodate larger RAW pixel streams and node info JSON over UDP
|
constexpr size_t UDP_BUF_SIZE = 64;
|
||||||
constexpr size_t UDP_BUF_SIZE = 2048;
|
|
||||||
constexpr const char* API_NODE_STATUS = "/api/node/status";
|
constexpr const char* API_NODE_STATUS = "/api/node/status";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
114
include/spore/types/ApiResponse.h
Normal file
114
include/spore/types/ApiResponse.h
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "spore/util/JsonSerializable.h"
|
||||||
|
#include "spore/util/Logging.h"
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
namespace spore {
|
||||||
|
namespace types {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for API responses that can be serialized to JSON
|
||||||
|
* Handles complete JsonDocument creation and serialization
|
||||||
|
*/
|
||||||
|
class ApiResponse {
|
||||||
|
protected:
|
||||||
|
mutable JsonDocument doc;
|
||||||
|
|
||||||
|
public:
|
||||||
|
virtual ~ApiResponse() = default;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the complete JSON string representation of this response
|
||||||
|
* @return JSON string ready to send as HTTP response
|
||||||
|
*/
|
||||||
|
virtual String toJsonString() const {
|
||||||
|
String json;
|
||||||
|
serializeJson(doc, json);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the JsonDocument for direct manipulation if needed
|
||||||
|
* @return Reference to the internal JsonDocument
|
||||||
|
*/
|
||||||
|
JsonDocument& getDocument() { return doc; }
|
||||||
|
const JsonDocument& getDocument() const { return doc; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the document and reset for reuse
|
||||||
|
*/
|
||||||
|
virtual void clear() {
|
||||||
|
doc.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for API responses that contain a collection of serializable items
|
||||||
|
*/
|
||||||
|
template<typename ItemType>
|
||||||
|
class CollectionResponse : public ApiResponse {
|
||||||
|
protected:
|
||||||
|
String collectionKey;
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit CollectionResponse(const String& key) : collectionKey(key) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a serializable item to the collection
|
||||||
|
* @param item The serializable item to add
|
||||||
|
*/
|
||||||
|
void addItem(const util::JsonSerializable& item) {
|
||||||
|
// Ensure the array exists and get a reference to it
|
||||||
|
if (!doc[collectionKey].is<JsonArray>()) {
|
||||||
|
doc[collectionKey] = JsonArray();
|
||||||
|
}
|
||||||
|
JsonArray arr = doc[collectionKey].as<JsonArray>();
|
||||||
|
JsonObject obj = arr.add<JsonObject>();
|
||||||
|
item.toJson(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a serializable item to the collection (move version)
|
||||||
|
* @param item The serializable item to add
|
||||||
|
*/
|
||||||
|
void addItem(util::JsonSerializable&& item) {
|
||||||
|
// Ensure the array exists and get a reference to it
|
||||||
|
if (!doc[collectionKey].is<JsonArray>()) {
|
||||||
|
doc[collectionKey] = JsonArray();
|
||||||
|
}
|
||||||
|
JsonArray arr = doc[collectionKey].as<JsonArray>();
|
||||||
|
JsonObject obj = arr.add<JsonObject>();
|
||||||
|
item.toJson(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add multiple items from a container
|
||||||
|
* @param items Container of serializable items
|
||||||
|
*/
|
||||||
|
template<typename Container>
|
||||||
|
void addItems(const Container& items) {
|
||||||
|
// Ensure the array exists and get a reference to it
|
||||||
|
if (!doc[collectionKey].is<JsonArray>()) {
|
||||||
|
doc[collectionKey] = JsonArray();
|
||||||
|
}
|
||||||
|
JsonArray arr = doc[collectionKey].as<JsonArray>();
|
||||||
|
for (const auto& item : items) {
|
||||||
|
JsonObject obj = arr.add<JsonObject>();
|
||||||
|
item.toJson(obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current number of items in the collection
|
||||||
|
* @return Number of items
|
||||||
|
*/
|
||||||
|
size_t getItemCount() const {
|
||||||
|
if (doc[collectionKey].is<JsonArray>()) {
|
||||||
|
return doc[collectionKey].as<JsonArray>().size();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace types
|
||||||
|
} // namespace spore
|
||||||
47
include/spore/types/ClusterResponse.h
Normal file
47
include/spore/types/ClusterResponse.h
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "ApiResponse.h"
|
||||||
|
#include "NodeInfoSerializable.h"
|
||||||
|
#include <map>
|
||||||
|
|
||||||
|
namespace spore {
|
||||||
|
namespace types {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response class for cluster members endpoint
|
||||||
|
* Handles complete JSON document creation for cluster member data
|
||||||
|
*/
|
||||||
|
class ClusterMembersResponse : public CollectionResponse<NodeInfoSerializable> {
|
||||||
|
public:
|
||||||
|
ClusterMembersResponse() : CollectionResponse("members") {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a single node to the response
|
||||||
|
* @param node The NodeInfo to add
|
||||||
|
*/
|
||||||
|
void addNode(const NodeInfo& node) {
|
||||||
|
addItem(NodeInfoSerializable(const_cast<NodeInfo&>(node)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add multiple nodes from a member list
|
||||||
|
* @param memberList Map of hostname to NodeInfo
|
||||||
|
*/
|
||||||
|
void addNodes(const std::map<String, NodeInfo>& memberList) {
|
||||||
|
for (const auto& pair : memberList) {
|
||||||
|
addNode(pair.second);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add nodes from a pointer to member list
|
||||||
|
* @param memberList Pointer to map of hostname to NodeInfo
|
||||||
|
*/
|
||||||
|
void addNodes(const std::map<String, NodeInfo>* memberList) {
|
||||||
|
if (memberList) {
|
||||||
|
addNodes(*memberList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace types
|
||||||
|
} // namespace spore
|
||||||
@@ -15,7 +15,6 @@ public:
|
|||||||
// Cluster Configuration
|
// Cluster Configuration
|
||||||
unsigned long discovery_interval_ms;
|
unsigned long discovery_interval_ms;
|
||||||
unsigned long heartbeat_interval_ms;
|
unsigned long heartbeat_interval_ms;
|
||||||
unsigned long cluster_listen_interval_ms;
|
|
||||||
unsigned long status_update_interval_ms;
|
unsigned long status_update_interval_ms;
|
||||||
unsigned long member_info_update_interval_ms;
|
unsigned long member_info_update_interval_ms;
|
||||||
unsigned long print_interval_ms;
|
unsigned long print_interval_ms;
|
||||||
|
|||||||
76
include/spore/types/EndpointInfoSerializable.h
Normal file
76
include/spore/types/EndpointInfoSerializable.h
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "ApiTypes.h"
|
||||||
|
#include "spore/util/JsonSerializable.h"
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
namespace spore {
|
||||||
|
namespace types {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializable wrapper for EndpointInfo that implements JsonSerializable interface
|
||||||
|
* Handles conversion between EndpointInfo struct and JSON representation
|
||||||
|
*/
|
||||||
|
class EndpointInfoSerializable : public util::JsonSerializable {
|
||||||
|
private:
|
||||||
|
const EndpointInfo& endpoint;
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit EndpointInfoSerializable(const EndpointInfo& ep) : endpoint(ep) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize EndpointInfo to JsonObject
|
||||||
|
*/
|
||||||
|
void toJson(JsonObject& obj) const override {
|
||||||
|
obj["uri"] = endpoint.uri;
|
||||||
|
obj["method"] = endpoint.method;
|
||||||
|
|
||||||
|
// Add parameters if present
|
||||||
|
if (!endpoint.params.empty()) {
|
||||||
|
JsonArray paramsArr = obj["params"].to<JsonArray>();
|
||||||
|
for (const auto& param : endpoint.params) {
|
||||||
|
JsonObject paramObj = paramsArr.add<JsonObject>();
|
||||||
|
paramObj["name"] = param.name;
|
||||||
|
paramObj["location"] = param.location;
|
||||||
|
paramObj["required"] = param.required;
|
||||||
|
paramObj["type"] = param.type;
|
||||||
|
|
||||||
|
if (!param.values.empty()) {
|
||||||
|
JsonArray valuesArr = paramObj["values"].to<JsonArray>();
|
||||||
|
for (const auto& value : param.values) {
|
||||||
|
valuesArr.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (param.defaultValue.length() > 0) {
|
||||||
|
paramObj["default"] = param.defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialize EndpointInfo from JsonObject
|
||||||
|
*/
|
||||||
|
void fromJson(const JsonObject& obj) override {
|
||||||
|
// Note: This would require modifying the EndpointInfo struct to be mutable
|
||||||
|
// For now, this is a placeholder as EndpointInfo is typically read-only
|
||||||
|
// in the context where this serialization is used
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience function to create a JsonArray from a collection of EndpointInfo objects
|
||||||
|
*/
|
||||||
|
template<typename Container>
|
||||||
|
JsonArray endpointInfoToJsonArray(JsonDocument& doc, const Container& endpoints) {
|
||||||
|
JsonArray arr = doc.to<JsonArray>();
|
||||||
|
for (const auto& endpoint : endpoints) {
|
||||||
|
EndpointInfoSerializable serializable(endpoint);
|
||||||
|
JsonObject obj = arr.add<JsonObject>();
|
||||||
|
serializable.toJson(obj);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace types
|
||||||
|
} // namespace spore
|
||||||
@@ -17,7 +17,7 @@ struct NodeInfo {
|
|||||||
uint32_t cpuFreqMHz = 0;
|
uint32_t cpuFreqMHz = 0;
|
||||||
uint32_t flashChipSize = 0;
|
uint32_t flashChipSize = 0;
|
||||||
} resources;
|
} resources;
|
||||||
unsigned long latency = 0; // ms from heartbeat broadcast to NODE_INFO receipt
|
unsigned long latency = 0; // ms since lastSeen
|
||||||
std::vector<EndpointInfo> endpoints; // List of registered endpoints
|
std::vector<EndpointInfo> endpoints; // List of registered endpoints
|
||||||
std::map<String, String> labels; // Arbitrary node labels (key -> value)
|
std::map<String, String> labels; // Arbitrary node labels (key -> value)
|
||||||
};
|
};
|
||||||
|
|||||||
141
include/spore/types/NodeInfoSerializable.h
Normal file
141
include/spore/types/NodeInfoSerializable.h
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "NodeInfo.h"
|
||||||
|
#include "ApiTypes.h"
|
||||||
|
#include "spore/util/JsonSerializable.h"
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
namespace spore {
|
||||||
|
namespace types {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializable wrapper for NodeInfo that implements JsonSerializable interface
|
||||||
|
* Handles conversion between NodeInfo struct and JSON representation
|
||||||
|
*/
|
||||||
|
class NodeInfoSerializable : public util::JsonSerializable {
|
||||||
|
private:
|
||||||
|
NodeInfo& nodeInfo;
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit NodeInfoSerializable(NodeInfo& node) : nodeInfo(node) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize NodeInfo to JsonObject
|
||||||
|
* Maps all NodeInfo fields to appropriate JSON structure
|
||||||
|
*/
|
||||||
|
void toJson(JsonObject& obj) const override {
|
||||||
|
obj["hostname"] = nodeInfo.hostname;
|
||||||
|
obj["ip"] = nodeInfo.ip.toString();
|
||||||
|
obj["lastSeen"] = nodeInfo.lastSeen;
|
||||||
|
obj["latency"] = nodeInfo.latency;
|
||||||
|
obj["status"] = statusToStr(nodeInfo.status);
|
||||||
|
|
||||||
|
// Serialize resources
|
||||||
|
JsonObject resources = obj["resources"].to<JsonObject>();
|
||||||
|
resources["freeHeap"] = nodeInfo.resources.freeHeap;
|
||||||
|
resources["chipId"] = nodeInfo.resources.chipId;
|
||||||
|
resources["sdkVersion"] = nodeInfo.resources.sdkVersion;
|
||||||
|
resources["cpuFreqMHz"] = nodeInfo.resources.cpuFreqMHz;
|
||||||
|
resources["flashChipSize"] = nodeInfo.resources.flashChipSize;
|
||||||
|
|
||||||
|
// Serialize labels if present
|
||||||
|
if (!nodeInfo.labels.empty()) {
|
||||||
|
JsonObject labels = obj["labels"].to<JsonObject>();
|
||||||
|
for (const auto& kv : nodeInfo.labels) {
|
||||||
|
labels[kv.first.c_str()] = kv.second;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize endpoints if present
|
||||||
|
if (!nodeInfo.endpoints.empty()) {
|
||||||
|
JsonArray endpoints = obj["api"].to<JsonArray>();
|
||||||
|
for (const auto& endpoint : nodeInfo.endpoints) {
|
||||||
|
JsonObject endpointObj = endpoints.add<JsonObject>();
|
||||||
|
endpointObj["uri"] = endpoint.uri;
|
||||||
|
endpointObj["method"] = endpoint.method;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialize NodeInfo from JsonObject
|
||||||
|
* Populates NodeInfo fields from JSON structure
|
||||||
|
*/
|
||||||
|
void fromJson(const JsonObject& obj) override {
|
||||||
|
nodeInfo.hostname = obj["hostname"].as<String>();
|
||||||
|
|
||||||
|
// Parse IP address
|
||||||
|
const char* ipStr = obj["ip"];
|
||||||
|
if (ipStr) {
|
||||||
|
nodeInfo.ip.fromString(ipStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeInfo.lastSeen = obj["lastSeen"].as<unsigned long>();
|
||||||
|
nodeInfo.latency = obj["latency"].as<unsigned long>();
|
||||||
|
|
||||||
|
// Parse status
|
||||||
|
const char* statusStr = obj["status"];
|
||||||
|
if (statusStr) {
|
||||||
|
if (strcmp(statusStr, "ACTIVE") == 0) {
|
||||||
|
nodeInfo.status = NodeInfo::ACTIVE;
|
||||||
|
} else if (strcmp(statusStr, "INACTIVE") == 0) {
|
||||||
|
nodeInfo.status = NodeInfo::INACTIVE;
|
||||||
|
} else if (strcmp(statusStr, "DEAD") == 0) {
|
||||||
|
nodeInfo.status = NodeInfo::DEAD;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse resources
|
||||||
|
if (obj["resources"].is<JsonObject>()) {
|
||||||
|
JsonObject resources = obj["resources"].as<JsonObject>();
|
||||||
|
nodeInfo.resources.freeHeap = resources["freeHeap"].as<uint32_t>();
|
||||||
|
nodeInfo.resources.chipId = resources["chipId"].as<uint32_t>();
|
||||||
|
nodeInfo.resources.sdkVersion = resources["sdkVersion"].as<String>();
|
||||||
|
nodeInfo.resources.cpuFreqMHz = resources["cpuFreqMHz"].as<uint32_t>();
|
||||||
|
nodeInfo.resources.flashChipSize = resources["flashChipSize"].as<uint32_t>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse labels
|
||||||
|
nodeInfo.labels.clear();
|
||||||
|
if (obj["labels"].is<JsonObject>()) {
|
||||||
|
JsonObject labels = obj["labels"].as<JsonObject>();
|
||||||
|
for (JsonPair kvp : labels) {
|
||||||
|
nodeInfo.labels[kvp.key().c_str()] = kvp.value().as<String>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse endpoints
|
||||||
|
nodeInfo.endpoints.clear();
|
||||||
|
if (obj["api"].is<JsonArray>()) {
|
||||||
|
JsonArray endpoints = obj["api"].as<JsonArray>();
|
||||||
|
for (JsonObject endpointObj : endpoints) {
|
||||||
|
EndpointInfo endpoint;
|
||||||
|
endpoint.uri = endpointObj["uri"].as<String>();
|
||||||
|
endpoint.method = endpointObj["method"].as<int>();
|
||||||
|
endpoint.isLocal = false;
|
||||||
|
endpoint.serviceName = "remote";
|
||||||
|
nodeInfo.endpoints.push_back(std::move(endpoint));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience function to create a JsonArray from a collection of NodeInfo objects
|
||||||
|
* @param doc The JsonDocument to create the array in
|
||||||
|
* @param nodes Collection of NodeInfo objects
|
||||||
|
* @return A JsonArray containing all serialized NodeInfo objects
|
||||||
|
*/
|
||||||
|
template<typename Container>
|
||||||
|
JsonArray nodeInfoToJsonArray(JsonDocument& doc, const Container& nodes) {
|
||||||
|
JsonArray arr = doc.to<JsonArray>();
|
||||||
|
for (const auto& pair : nodes) {
|
||||||
|
const NodeInfo& node = pair.second;
|
||||||
|
NodeInfoSerializable serializable(const_cast<NodeInfo&>(node));
|
||||||
|
JsonObject obj = arr.add<JsonObject>();
|
||||||
|
serializable.toJson(obj);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace types
|
||||||
|
} // namespace spore
|
||||||
102
include/spore/types/NodeResponse.h
Normal file
102
include/spore/types/NodeResponse.h
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "ApiResponse.h"
|
||||||
|
#include "EndpointInfoSerializable.h"
|
||||||
|
#include "NodeInfo.h"
|
||||||
|
#include "spore/util/Logging.h"
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace spore {
|
||||||
|
namespace types {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response class for node status endpoint
|
||||||
|
* Handles complete JSON document creation for node status data
|
||||||
|
*/
|
||||||
|
class NodeStatusResponse : public ApiResponse {
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* Set basic system information
|
||||||
|
*/
|
||||||
|
void setSystemInfo() {
|
||||||
|
doc["freeHeap"] = ESP.getFreeHeap();
|
||||||
|
doc["chipId"] = ESP.getChipId();
|
||||||
|
doc["sdkVersion"] = ESP.getSdkVersion();
|
||||||
|
doc["cpuFreqMHz"] = ESP.getCpuFreqMHz();
|
||||||
|
doc["flashChipSize"] = ESP.getFlashChipSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add labels to the response
|
||||||
|
* @param labels Map of label key-value pairs
|
||||||
|
*/
|
||||||
|
void addLabels(const std::map<String, String>& labels) {
|
||||||
|
if (!labels.empty()) {
|
||||||
|
JsonObject labelsObj = doc["labels"].to<JsonObject>();
|
||||||
|
for (const auto& kv : labels) {
|
||||||
|
labelsObj[kv.first.c_str()] = kv.second;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build complete response with system info and labels
|
||||||
|
* @param labels Optional labels to include
|
||||||
|
*/
|
||||||
|
void buildCompleteResponse(const std::map<String, String>& labels = {}) {
|
||||||
|
setSystemInfo();
|
||||||
|
addLabels(labels);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response class for node endpoints endpoint
|
||||||
|
* Handles complete JSON document creation for endpoint data
|
||||||
|
*/
|
||||||
|
class NodeEndpointsResponse : public CollectionResponse<EndpointInfoSerializable> {
|
||||||
|
public:
|
||||||
|
NodeEndpointsResponse() : CollectionResponse("endpoints") {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a single endpoint to the response
|
||||||
|
* @param endpoint The EndpointInfo to add
|
||||||
|
*/
|
||||||
|
void addEndpoint(const EndpointInfo& endpoint) {
|
||||||
|
addItem(EndpointInfoSerializable(endpoint));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add multiple endpoints from a container
|
||||||
|
* @param endpoints Container of EndpointInfo objects
|
||||||
|
*/
|
||||||
|
void addEndpoints(const std::vector<EndpointInfo>& endpoints) {
|
||||||
|
for (const auto& endpoint : endpoints) {
|
||||||
|
addEndpoint(endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response class for simple status operations (update, restart)
|
||||||
|
* Handles simple JSON responses for node operations
|
||||||
|
*/
|
||||||
|
class NodeOperationResponse : public ApiResponse {
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* Set success response
|
||||||
|
* @param status Status message
|
||||||
|
*/
|
||||||
|
void setSuccess(const String& status) {
|
||||||
|
doc["status"] = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set error response
|
||||||
|
* @param status Error status message
|
||||||
|
*/
|
||||||
|
void setError(const String& status) {
|
||||||
|
doc["status"] = status;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace types
|
||||||
|
} // namespace spore
|
||||||
99
include/spore/types/TaskInfoSerializable.h
Normal file
99
include/spore/types/TaskInfoSerializable.h
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "spore/util/JsonSerializable.h"
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
namespace spore {
|
||||||
|
namespace types {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializable wrapper for task information that implements JsonSerializable interface
|
||||||
|
* Handles conversion between task data and JSON representation
|
||||||
|
*/
|
||||||
|
class TaskInfoSerializable : public util::JsonSerializable {
|
||||||
|
private:
|
||||||
|
String taskName;
|
||||||
|
const JsonObject& taskData;
|
||||||
|
|
||||||
|
public:
|
||||||
|
TaskInfoSerializable(const String& name, const JsonObject& data)
|
||||||
|
: taskName(name), taskData(data) {}
|
||||||
|
|
||||||
|
TaskInfoSerializable(const std::string& name, const JsonObject& data)
|
||||||
|
: taskName(name.c_str()), taskData(data) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize task info to JsonObject
|
||||||
|
*/
|
||||||
|
void toJson(JsonObject& obj) const override {
|
||||||
|
obj["name"] = taskName;
|
||||||
|
obj["interval"] = taskData["interval"];
|
||||||
|
obj["enabled"] = taskData["enabled"];
|
||||||
|
obj["running"] = taskData["running"];
|
||||||
|
obj["autoStart"] = taskData["autoStart"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialize task info from JsonObject
|
||||||
|
* Note: This is read-only for task status, so fromJson is not implemented
|
||||||
|
*/
|
||||||
|
void fromJson(const JsonObject& obj) override {
|
||||||
|
// Task info is typically read-only in this context
|
||||||
|
// Implementation would go here if needed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializable wrapper for system information
|
||||||
|
*/
|
||||||
|
class SystemInfoSerializable : public util::JsonSerializable {
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* Serialize system info to JsonObject
|
||||||
|
*/
|
||||||
|
void toJson(JsonObject& obj) const override {
|
||||||
|
obj["freeHeap"] = ESP.getFreeHeap();
|
||||||
|
obj["uptime"] = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialize system info from JsonObject
|
||||||
|
* Note: System info is typically read-only, so fromJson is not implemented
|
||||||
|
*/
|
||||||
|
void fromJson(const JsonObject& obj) override {
|
||||||
|
// System info is typically read-only
|
||||||
|
// Implementation would go here if needed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializable wrapper for task summary information
|
||||||
|
*/
|
||||||
|
class TaskSummarySerializable : public util::JsonSerializable {
|
||||||
|
private:
|
||||||
|
size_t totalTasks;
|
||||||
|
size_t activeTasks;
|
||||||
|
|
||||||
|
public:
|
||||||
|
TaskSummarySerializable(size_t total, size_t active)
|
||||||
|
: totalTasks(total), activeTasks(active) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize task summary to JsonObject
|
||||||
|
*/
|
||||||
|
void toJson(JsonObject& obj) const override {
|
||||||
|
obj["totalTasks"] = totalTasks;
|
||||||
|
obj["activeTasks"] = activeTasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialize task summary from JsonObject
|
||||||
|
*/
|
||||||
|
void fromJson(const JsonObject& obj) override {
|
||||||
|
totalTasks = obj["totalTasks"].as<size_t>();
|
||||||
|
activeTasks = obj["activeTasks"].as<size_t>();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace types
|
||||||
|
} // namespace spore
|
||||||
194
include/spore/types/TaskResponse.h
Normal file
194
include/spore/types/TaskResponse.h
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "ApiResponse.h"
|
||||||
|
#include "TaskInfoSerializable.h"
|
||||||
|
#include <map>
|
||||||
|
|
||||||
|
namespace spore {
|
||||||
|
namespace types {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response class for task status endpoint
|
||||||
|
* Handles complete JSON document creation for task status data
|
||||||
|
*/
|
||||||
|
class TaskStatusResponse : public ApiResponse {
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* Set the task summary information
|
||||||
|
* @param totalTasks Total number of tasks
|
||||||
|
* @param activeTasks Number of active tasks
|
||||||
|
*/
|
||||||
|
void setSummary(size_t totalTasks, size_t activeTasks) {
|
||||||
|
TaskSummarySerializable summary(totalTasks, activeTasks);
|
||||||
|
JsonObject summaryObj = doc["summary"].to<JsonObject>();
|
||||||
|
summary.toJson(summaryObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a single task to the response
|
||||||
|
* @param taskName Name of the task
|
||||||
|
* @param taskData Task data as JsonObject
|
||||||
|
*/
|
||||||
|
void addTask(const String& taskName, const JsonObject& taskData) {
|
||||||
|
TaskInfoSerializable serializable(taskName, taskData);
|
||||||
|
if (!doc["tasks"].is<JsonArray>()) {
|
||||||
|
doc["tasks"] = JsonArray();
|
||||||
|
}
|
||||||
|
JsonArray tasksArr = doc["tasks"].as<JsonArray>();
|
||||||
|
JsonObject taskObj = tasksArr.add<JsonObject>();
|
||||||
|
serializable.toJson(taskObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a single task to the response (std::string version)
|
||||||
|
* @param taskName Name of the task
|
||||||
|
* @param taskData Task data as JsonObject
|
||||||
|
*/
|
||||||
|
void addTask(const std::string& taskName, const JsonObject& taskData) {
|
||||||
|
TaskInfoSerializable serializable(taskName, taskData);
|
||||||
|
if (!doc["tasks"].is<JsonArray>()) {
|
||||||
|
doc["tasks"] = JsonArray();
|
||||||
|
}
|
||||||
|
JsonArray tasksArr = doc["tasks"].as<JsonArray>();
|
||||||
|
JsonObject taskObj = tasksArr.add<JsonObject>();
|
||||||
|
serializable.toJson(taskObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add multiple tasks from a task statuses map
|
||||||
|
* @param taskStatuses Map of task name to task data
|
||||||
|
*/
|
||||||
|
void addTasks(const std::map<String, JsonObject>& taskStatuses) {
|
||||||
|
for (const auto& pair : taskStatuses) {
|
||||||
|
addTask(pair.first, pair.second);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the system information
|
||||||
|
*/
|
||||||
|
void setSystemInfo() {
|
||||||
|
SystemInfoSerializable systemInfo;
|
||||||
|
JsonObject systemObj = doc["system"].to<JsonObject>();
|
||||||
|
systemInfo.toJson(systemObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build complete response with all components
|
||||||
|
* @param taskStatuses Map of task name to task data
|
||||||
|
*/
|
||||||
|
void buildCompleteResponse(const std::map<String, JsonObject>& taskStatuses) {
|
||||||
|
// Set summary
|
||||||
|
size_t totalTasks = taskStatuses.size();
|
||||||
|
size_t activeTasks = 0;
|
||||||
|
for (const auto& pair : taskStatuses) {
|
||||||
|
if (pair.second["enabled"]) {
|
||||||
|
activeTasks++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSummary(totalTasks, activeTasks);
|
||||||
|
|
||||||
|
// Add all tasks
|
||||||
|
addTasks(taskStatuses);
|
||||||
|
|
||||||
|
// Set system info
|
||||||
|
setSystemInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build complete response with all components from vector
|
||||||
|
* @param taskStatuses Vector of pairs of task name to task data
|
||||||
|
*/
|
||||||
|
void buildCompleteResponse(const std::vector<std::pair<std::string, JsonObject>>& taskStatuses) {
|
||||||
|
// Clear the document first since getAllTaskStatuses creates a root array
|
||||||
|
doc.clear();
|
||||||
|
|
||||||
|
// Set summary
|
||||||
|
size_t totalTasks = taskStatuses.size();
|
||||||
|
size_t activeTasks = 0;
|
||||||
|
for (const auto& pair : taskStatuses) {
|
||||||
|
if (pair.second["enabled"]) {
|
||||||
|
activeTasks++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSummary(totalTasks, activeTasks);
|
||||||
|
|
||||||
|
// Add all tasks - extract data before clearing to avoid invalid references
|
||||||
|
JsonArray tasksArr = doc["tasks"].to<JsonArray>();
|
||||||
|
for (const auto& pair : taskStatuses) {
|
||||||
|
// Extract data from JsonObject before it becomes invalid
|
||||||
|
String taskName = pair.first.c_str();
|
||||||
|
unsigned long interval = pair.second["interval"];
|
||||||
|
bool enabled = pair.second["enabled"];
|
||||||
|
bool running = pair.second["running"];
|
||||||
|
bool autoStart = pair.second["autoStart"];
|
||||||
|
|
||||||
|
// Create new JsonObject in our document
|
||||||
|
JsonObject taskObj = tasksArr.add<JsonObject>();
|
||||||
|
taskObj["name"] = taskName;
|
||||||
|
taskObj["interval"] = interval;
|
||||||
|
taskObj["enabled"] = enabled;
|
||||||
|
taskObj["running"] = running;
|
||||||
|
taskObj["autoStart"] = autoStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set system info
|
||||||
|
setSystemInfo();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response class for task control operations
|
||||||
|
* Handles JSON responses for task enable/disable/start/stop operations
|
||||||
|
*/
|
||||||
|
class TaskControlResponse : public ApiResponse {
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* Set the response data for a task control operation
|
||||||
|
* @param success Whether the operation was successful
|
||||||
|
* @param message Response message
|
||||||
|
* @param taskName Name of the task
|
||||||
|
* @param action Action performed
|
||||||
|
*/
|
||||||
|
void setResponse(bool success, const String& message, const String& taskName, const String& action) {
|
||||||
|
doc["success"] = success;
|
||||||
|
doc["message"] = message;
|
||||||
|
doc["task"] = taskName;
|
||||||
|
doc["action"] = action;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add detailed task information to the response
|
||||||
|
* @param taskName Name of the task
|
||||||
|
* @param enabled Whether task is enabled
|
||||||
|
* @param running Whether task is running
|
||||||
|
* @param interval Task interval
|
||||||
|
*/
|
||||||
|
void addTaskDetails(const String& taskName, bool enabled, bool running, unsigned long interval) {
|
||||||
|
JsonObject taskDetails = doc["taskDetails"].to<JsonObject>();
|
||||||
|
taskDetails["name"] = taskName;
|
||||||
|
taskDetails["enabled"] = enabled;
|
||||||
|
taskDetails["running"] = running;
|
||||||
|
taskDetails["interval"] = interval;
|
||||||
|
|
||||||
|
// Add system info
|
||||||
|
SystemInfoSerializable systemInfo;
|
||||||
|
JsonObject systemObj = taskDetails["system"].to<JsonObject>();
|
||||||
|
systemInfo.toJson(systemObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set error response
|
||||||
|
* @param message Error message
|
||||||
|
* @param example Optional example for correct usage
|
||||||
|
*/
|
||||||
|
void setError(const String& message, const String& example = "") {
|
||||||
|
doc["success"] = false;
|
||||||
|
doc["message"] = message;
|
||||||
|
if (example.length() > 0) {
|
||||||
|
doc["example"] = example;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace types
|
||||||
|
} // namespace spore
|
||||||
56
include/spore/util/JsonSerializable.h
Normal file
56
include/spore/util/JsonSerializable.h
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
namespace spore {
|
||||||
|
namespace util {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract base class for objects that can be serialized to/from JSON
|
||||||
|
* Provides a clean interface for converting objects to JsonObject and back
|
||||||
|
*/
|
||||||
|
class JsonSerializable {
|
||||||
|
public:
|
||||||
|
virtual ~JsonSerializable() = default;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize this object to a JsonObject
|
||||||
|
* @param obj The JsonObject to populate with this object's data
|
||||||
|
*/
|
||||||
|
virtual void toJson(JsonObject& obj) const = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialize this object from a JsonObject
|
||||||
|
* @param obj The JsonObject containing the data to populate this object
|
||||||
|
*/
|
||||||
|
virtual void fromJson(const JsonObject& obj) = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method to create a JsonObject from this object
|
||||||
|
* @param doc The JsonDocument to create the object in
|
||||||
|
* @return A JsonObject containing this object's serialized data
|
||||||
|
*/
|
||||||
|
JsonObject toJsonObject(JsonDocument& doc) const {
|
||||||
|
JsonObject obj = doc.to<JsonObject>();
|
||||||
|
toJson(obj);
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method to create a JsonArray from a collection of serializable objects
|
||||||
|
* @param doc The JsonDocument to create the array in
|
||||||
|
* @param objects Collection of objects implementing JsonSerializable
|
||||||
|
* @return A JsonArray containing all serialized objects
|
||||||
|
*/
|
||||||
|
template<typename Container>
|
||||||
|
static JsonArray toJsonArray(JsonDocument& doc, const Container& objects) {
|
||||||
|
JsonArray arr = doc.to<JsonArray>();
|
||||||
|
for (const auto& obj : objects) {
|
||||||
|
JsonObject item = arr.add<JsonObject>();
|
||||||
|
obj.toJson(item);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace util
|
||||||
|
} // namespace spore
|
||||||
@@ -100,68 +100,3 @@ build_src_filter =
|
|||||||
+<src/spore/types/*.cpp>
|
+<src/spore/types/*.cpp>
|
||||||
+<src/spore/util/*.cpp>
|
+<src/spore/util/*.cpp>
|
||||||
+<src/internal/*.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>
|
|
||||||
|
|||||||
@@ -21,57 +21,31 @@ void ApiServer::registerEndpoint(const String& uri, int method,
|
|||||||
const String& serviceName) {
|
const String& serviceName) {
|
||||||
// Add to local endpoints
|
// Add to local endpoints
|
||||||
endpoints.push_back(EndpointInfo{uri, method, params, serviceName, true});
|
endpoints.push_back(EndpointInfo{uri, method, params, serviceName, true});
|
||||||
|
|
||||||
// Update cluster if needed
|
|
||||||
if (ctx.memberList && !ctx.memberList->empty()) {
|
|
||||||
auto it = ctx.memberList->find(ctx.hostname);
|
|
||||||
if (it != ctx.memberList->end()) {
|
|
||||||
it->second.endpoints.push_back(EndpointInfo{uri, method, params, serviceName, true});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void ApiServer::addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler) {
|
void ApiServer::addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||||
// Get current service name if available
|
const String& serviceName) {
|
||||||
String serviceName = "unknown";
|
|
||||||
if (!services.empty()) {
|
|
||||||
serviceName = services.back().get().getName();
|
|
||||||
}
|
|
||||||
registerEndpoint(uri, method, {}, serviceName);
|
registerEndpoint(uri, method, {}, serviceName);
|
||||||
server.on(uri.c_str(), method, requestHandler);
|
server.on(uri.c_str(), method, requestHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ApiServer::addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
void ApiServer::addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||||
std::function<void(AsyncWebServerRequest*, const String&, size_t, uint8_t*, size_t, bool)> uploadHandler) {
|
std::function<void(AsyncWebServerRequest*, const String&, size_t, uint8_t*, size_t, bool)> uploadHandler,
|
||||||
// Get current service name if available
|
const String& serviceName) {
|
||||||
String serviceName = "unknown";
|
|
||||||
if (!services.empty()) {
|
|
||||||
serviceName = services.back().get().getName();
|
|
||||||
}
|
|
||||||
registerEndpoint(uri, method, {}, serviceName);
|
registerEndpoint(uri, method, {}, serviceName);
|
||||||
server.on(uri.c_str(), method, requestHandler, uploadHandler);
|
server.on(uri.c_str(), method, requestHandler, uploadHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overloads that also record minimal capability specs
|
// Overloads that also record minimal capability specs
|
||||||
void ApiServer::addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
void ApiServer::addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||||
const std::vector<ParamSpec>& params) {
|
const std::vector<ParamSpec>& params, const String& serviceName) {
|
||||||
// Get current service name if available
|
|
||||||
String serviceName = "unknown";
|
|
||||||
if (!services.empty()) {
|
|
||||||
serviceName = services.back().get().getName();
|
|
||||||
}
|
|
||||||
registerEndpoint(uri, method, params, serviceName);
|
registerEndpoint(uri, method, params, serviceName);
|
||||||
server.on(uri.c_str(), method, requestHandler);
|
server.on(uri.c_str(), method, requestHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ApiServer::addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
void ApiServer::addEndpoint(const String& uri, int method, std::function<void(AsyncWebServerRequest*)> requestHandler,
|
||||||
std::function<void(AsyncWebServerRequest*, const String&, size_t, uint8_t*, size_t, bool)> uploadHandler,
|
std::function<void(AsyncWebServerRequest*, const String&, size_t, uint8_t*, size_t, bool)> uploadHandler,
|
||||||
const std::vector<ParamSpec>& params) {
|
const std::vector<ParamSpec>& params, const String& serviceName) {
|
||||||
// Get current service name if available
|
|
||||||
String serviceName = "unknown";
|
|
||||||
if (!services.empty()) {
|
|
||||||
serviceName = services.back().get().getName();
|
|
||||||
}
|
|
||||||
registerEndpoint(uri, method, params, serviceName);
|
registerEndpoint(uri, method, params, serviceName);
|
||||||
server.on(uri.c_str(), method, requestHandler, uploadHandler);
|
server.on(uri.c_str(), method, requestHandler, uploadHandler);
|
||||||
}
|
}
|
||||||
@@ -87,10 +61,6 @@ void ApiServer::serveStatic(const String& uri, fs::FS& fs, const String& path, c
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ApiServer::begin() {
|
void ApiServer::begin() {
|
||||||
// Setup streaming API (WebSocket)
|
|
||||||
setupWebSocket();
|
|
||||||
server.addHandler(&ws);
|
|
||||||
|
|
||||||
// Register all service endpoints
|
// Register all service endpoints
|
||||||
for (auto& service : services) {
|
for (auto& service : services) {
|
||||||
service.get().registerEndpoints(*this);
|
service.get().registerEndpoints(*this);
|
||||||
@@ -99,95 +69,3 @@ void ApiServer::begin() {
|
|||||||
|
|
||||||
server.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,35 +8,17 @@ ClusterManager::ClusterManager(NodeContext& ctx, TaskManager& taskMgr) : ctx(ctx
|
|||||||
NodeInfo* node = static_cast<NodeInfo*>(data);
|
NodeInfo* node = static_cast<NodeInfo*>(data);
|
||||||
this->addOrUpdateNode(node->hostname, node->ip);
|
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
|
// Register tasks
|
||||||
registerTasks();
|
registerTasks();
|
||||||
initMessageHandlers();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void ClusterManager::registerTasks() {
|
void ClusterManager::registerTasks() {
|
||||||
taskManager.registerTask("cluster_discovery", ctx.config.discovery_interval_ms, [this]() { sendDiscovery(); });
|
taskManager.registerTask("discovery_send", ctx.config.discovery_interval_ms, [this]() { sendDiscovery(); });
|
||||||
taskManager.registerTask("cluster_listen", ctx.config.cluster_listen_interval_ms, [this]() { listen(); });
|
taskManager.registerTask("discovery_listen", ctx.config.discovery_interval_ms / 10, [this]() { listenForDiscovery(); });
|
||||||
taskManager.registerTask("status_update", ctx.config.status_update_interval_ms, [this]() { updateAllNodeStatuses(); removeDeadNodes(); });
|
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("print_members", ctx.config.print_interval_ms, [this]() { printMemberList(); });
|
||||||
taskManager.registerTask("heartbeat", ctx.config.heartbeat_interval_ms, [this]() { heartbeatTaskCallback(); });
|
taskManager.registerTask("heartbeat", ctx.config.heartbeat_interval_ms, [this]() { heartbeatTaskCallback(); });
|
||||||
taskManager.registerTask("cluster_update_members_info", ctx.config.member_info_update_interval_ms, [this]() { updateAllMembersInfoTaskCallback(); });
|
taskManager.registerTask("update_members_info", ctx.config.member_info_update_interval_ms, [this]() { updateAllMembersInfoTaskCallback(); });
|
||||||
LOG_INFO("ClusterManager", "Registered all cluster tasks");
|
LOG_INFO("ClusterManager", "Registered all cluster tasks");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,224 +29,28 @@ void ClusterManager::sendDiscovery() {
|
|||||||
ctx.udp->endPacket();
|
ctx.udp->endPacket();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ClusterManager::listen() {
|
void ClusterManager::listenForDiscovery() {
|
||||||
int packetSize = ctx.udp->parsePacket();
|
int packetSize = ctx.udp->parsePacket();
|
||||||
if (!packetSize) {
|
if (packetSize) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
char incoming[ClusterProtocol::UDP_BUF_SIZE];
|
char incoming[ClusterProtocol::UDP_BUF_SIZE];
|
||||||
int len = ctx.udp->read(incoming, ClusterProtocol::UDP_BUF_SIZE);
|
int len = ctx.udp->read(incoming, ClusterProtocol::UDP_BUF_SIZE);
|
||||||
if (len <= 0) {
|
if (len > 0) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (len >= (int)ClusterProtocol::UDP_BUF_SIZE) {
|
|
||||||
incoming[ClusterProtocol::UDP_BUF_SIZE - 1] = 0;
|
|
||||||
} else {
|
|
||||||
incoming[len] = 0;
|
incoming[len] = 0;
|
||||||
}
|
}
|
||||||
handleIncomingMessage(incoming);
|
//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());
|
||||||
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);
|
ctx.udp->beginPacket(ctx.udp->remoteIP(), ctx.config.udp_port);
|
||||||
String response = String(ClusterProtocol::RESPONSE_MSG) + ":" + ctx.hostname;
|
String response = String(ClusterProtocol::RESPONSE_MSG) + ":" + ctx.hostname;
|
||||||
ctx.udp->write(response.c_str());
|
ctx.udp->write(response.c_str());
|
||||||
ctx.udp->endPacket();
|
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) {
|
||||||
void ClusterManager::onHeartbeat(const char* /*msg*/) {
|
char* hostPtr = incoming + strlen(ClusterProtocol::RESPONSE_MSG) + 1;
|
||||||
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);
|
String nodeHost = String(hostPtr);
|
||||||
addOrUpdateNode(nodeHost, ctx.udp->remoteIP());
|
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) {
|
void ClusterManager::addOrUpdateNode(const String& nodeHost, IPAddress nodeIP) {
|
||||||
@@ -291,7 +77,6 @@ void ClusterManager::addOrUpdateNode(const String& nodeHost, IPAddress nodeIP) {
|
|||||||
//fetchNodeInfo(nodeIP); // Do not fetch here, handled by periodic task
|
//fetchNodeInfo(nodeIP); // Do not fetch here, handled by periodic task
|
||||||
}
|
}
|
||||||
|
|
||||||
// unused http client to fetch complete node info
|
|
||||||
void ClusterManager::fetchNodeInfo(const IPAddress& ip) {
|
void ClusterManager::fetchNodeInfo(const IPAddress& ip) {
|
||||||
if(ip == ctx.localIP) {
|
if(ip == ctx.localIP) {
|
||||||
LOG_DEBUG("Cluster", "Skipping fetch for local node");
|
LOG_DEBUG("Cluster", "Skipping fetch for local node");
|
||||||
@@ -407,20 +192,33 @@ void ClusterManager::heartbeatTaskCallback() {
|
|||||||
node.lastSeen = millis();
|
node.lastSeen = millis();
|
||||||
node.status = NodeInfo::ACTIVE;
|
node.status = NodeInfo::ACTIVE;
|
||||||
updateLocalNodeResources();
|
updateLocalNodeResources();
|
||||||
addOrUpdateNode(ctx.hostname, ctx.localIP);
|
ctx.fire("node_discovered", &node);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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() {
|
void ClusterManager::updateAllMembersInfoTaskCallback() {
|
||||||
// HTTP-based member info fetching disabled; node info is provided via UDP responses to heartbeats
|
auto& memberList = *ctx.memberList;
|
||||||
// No-op to reduce network and memory usage
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ClusterManager::updateAllNodeStatuses() {
|
void ClusterManager::updateAllNodeStatuses() {
|
||||||
|
|||||||
@@ -29,11 +29,4 @@ void NodeContext::fire(const std::string& event, void* data) {
|
|||||||
for (auto& cb : eventRegistry[event]) {
|
for (auto& cb : eventRegistry[event]) {
|
||||||
cb(data);
|
cb(data);
|
||||||
}
|
}
|
||||||
for (auto& acb : anyEventSubscribers) {
|
|
||||||
acb(event, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void NodeContext::onAny(AnyEventCallback cb) {
|
|
||||||
anyEventSubscribers.push_back(cb);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +1,19 @@
|
|||||||
#include "spore/services/ClusterService.h"
|
#include "spore/services/ClusterService.h"
|
||||||
#include "spore/core/ApiServer.h"
|
#include "spore/core/ApiServer.h"
|
||||||
|
#include "spore/types/ClusterResponse.h"
|
||||||
|
|
||||||
|
using spore::types::ClusterMembersResponse;
|
||||||
|
|
||||||
ClusterService::ClusterService(NodeContext& ctx) : ctx(ctx) {}
|
ClusterService::ClusterService(NodeContext& ctx) : ctx(ctx) {}
|
||||||
|
|
||||||
void ClusterService::registerEndpoints(ApiServer& api) {
|
void ClusterService::registerEndpoints(ApiServer& api) {
|
||||||
api.addEndpoint("/api/cluster/members", HTTP_GET,
|
api.addEndpoint("/api/cluster/members", HTTP_GET,
|
||||||
[this](AsyncWebServerRequest* request) { handleMembersRequest(request); },
|
[this](AsyncWebServerRequest* request) { handleMembersRequest(request); },
|
||||||
std::vector<ParamSpec>{});
|
std::vector<ParamSpec>{}, "ClusterService");
|
||||||
|
|
||||||
// Generic cluster broadcast endpoint
|
|
||||||
api.addEndpoint("/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::handleMembersRequest(AsyncWebServerRequest* request) {
|
void ClusterService::handleMembersRequest(AsyncWebServerRequest* request) {
|
||||||
JsonDocument doc;
|
ClusterMembersResponse response;
|
||||||
JsonArray arr = doc["members"].to<JsonArray>();
|
response.addNodes(ctx.memberList);
|
||||||
|
request->send(200, "application/json", response.toJsonString());
|
||||||
for (const auto& pair : *ctx.memberList) {
|
|
||||||
const NodeInfo& node = pair.second;
|
|
||||||
JsonObject obj = arr.add<JsonObject>();
|
|
||||||
obj["hostname"] = node.hostname;
|
|
||||||
obj["ip"] = node.ip.toString();
|
|
||||||
obj["lastSeen"] = node.lastSeen;
|
|
||||||
obj["latency"] = node.latency;
|
|
||||||
obj["status"] = statusToStr(node.status);
|
|
||||||
|
|
||||||
// Add labels if present
|
|
||||||
if (!node.labels.empty()) {
|
|
||||||
JsonObject labelsObj = obj["labels"].to<JsonObject>();
|
|
||||||
for (const auto& kv : node.labels) {
|
|
||||||
labelsObj[kv.first.c_str()] = kv.second;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String json;
|
|
||||||
serializeJson(doc, json);
|
|
||||||
request->send(200, "application/json", json);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ MonitoringService::MonitoringService(CpuUsage& cpuUsage)
|
|||||||
void MonitoringService::registerEndpoints(ApiServer& api) {
|
void MonitoringService::registerEndpoints(ApiServer& api) {
|
||||||
api.addEndpoint("/api/monitoring/resources", HTTP_GET,
|
api.addEndpoint("/api/monitoring/resources", HTTP_GET,
|
||||||
[this](AsyncWebServerRequest* request) { handleResourcesRequest(request); },
|
[this](AsyncWebServerRequest* request) { handleResourcesRequest(request); },
|
||||||
std::vector<ParamSpec>{});
|
std::vector<ParamSpec>{}, "MonitoringService");
|
||||||
}
|
}
|
||||||
|
|
||||||
MonitoringService::SystemResources MonitoringService::getSystemResources() const {
|
MonitoringService::SystemResources MonitoringService::getSystemResources() const {
|
||||||
|
|||||||
@@ -8,16 +8,16 @@ void NetworkService::registerEndpoints(ApiServer& api) {
|
|||||||
// WiFi scanning endpoints
|
// WiFi scanning endpoints
|
||||||
api.addEndpoint("/api/network/wifi/scan", HTTP_POST,
|
api.addEndpoint("/api/network/wifi/scan", HTTP_POST,
|
||||||
[this](AsyncWebServerRequest* request) { handleWifiScanRequest(request); },
|
[this](AsyncWebServerRequest* request) { handleWifiScanRequest(request); },
|
||||||
std::vector<ParamSpec>{});
|
std::vector<ParamSpec>{}, "NetworkService");
|
||||||
|
|
||||||
api.addEndpoint("/api/network/wifi/scan", HTTP_GET,
|
api.addEndpoint("/api/network/wifi/scan", HTTP_GET,
|
||||||
[this](AsyncWebServerRequest* request) { handleGetWifiNetworks(request); },
|
[this](AsyncWebServerRequest* request) { handleGetWifiNetworks(request); },
|
||||||
std::vector<ParamSpec>{});
|
std::vector<ParamSpec>{}, "NetworkService");
|
||||||
|
|
||||||
// Network status and configuration endpoints
|
// Network status and configuration endpoints
|
||||||
api.addEndpoint("/api/network/status", HTTP_GET,
|
api.addEndpoint("/api/network/status", HTTP_GET,
|
||||||
[this](AsyncWebServerRequest* request) { handleNetworkStatus(request); },
|
[this](AsyncWebServerRequest* request) { handleNetworkStatus(request); },
|
||||||
std::vector<ParamSpec>{});
|
std::vector<ParamSpec>{}, "NetworkService");
|
||||||
|
|
||||||
api.addEndpoint("/api/network/wifi/config", HTTP_POST,
|
api.addEndpoint("/api/network/wifi/config", HTTP_POST,
|
||||||
[this](AsyncWebServerRequest* request) { handleSetWifiConfig(request); },
|
[this](AsyncWebServerRequest* request) { handleSetWifiConfig(request); },
|
||||||
@@ -26,7 +26,7 @@ void NetworkService::registerEndpoints(ApiServer& api) {
|
|||||||
ParamSpec{String("password"), true, String("body"), String("string"), {}, String("")},
|
ParamSpec{String("password"), true, String("body"), String("string"), {}, String("")},
|
||||||
ParamSpec{String("connect_timeout_ms"), false, String("body"), String("number"), {}, String("10000")},
|
ParamSpec{String("connect_timeout_ms"), false, String("body"), String("number"), {}, String("10000")},
|
||||||
ParamSpec{String("retry_delay_ms"), false, String("body"), String("number"), {}, String("500")}
|
ParamSpec{String("retry_delay_ms"), false, String("body"), String("number"), {}, String("500")}
|
||||||
});
|
}, "NetworkService");
|
||||||
}
|
}
|
||||||
|
|
||||||
void NetworkService::handleWifiScanRequest(AsyncWebServerRequest* request) {
|
void NetworkService::handleWifiScanRequest(AsyncWebServerRequest* request) {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
#include "spore/services/NodeService.h"
|
#include "spore/services/NodeService.h"
|
||||||
#include "spore/core/ApiServer.h"
|
#include "spore/core/ApiServer.h"
|
||||||
#include "spore/util/Logging.h"
|
#include "spore/util/Logging.h"
|
||||||
|
#include "spore/types/NodeResponse.h"
|
||||||
|
|
||||||
|
using spore::types::NodeStatusResponse;
|
||||||
|
using spore::types::NodeOperationResponse;
|
||||||
|
using spore::types::NodeEndpointsResponse;
|
||||||
|
|
||||||
NodeService::NodeService(NodeContext& ctx, ApiServer& apiServer) : ctx(ctx), apiServer(apiServer) {}
|
NodeService::NodeService(NodeContext& ctx, ApiServer& apiServer) : ctx(ctx), apiServer(apiServer) {}
|
||||||
|
|
||||||
@@ -8,7 +13,7 @@ void NodeService::registerEndpoints(ApiServer& api) {
|
|||||||
// Status endpoint
|
// Status endpoint
|
||||||
api.addEndpoint("/api/node/status", HTTP_GET,
|
api.addEndpoint("/api/node/status", HTTP_GET,
|
||||||
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
|
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
|
||||||
std::vector<ParamSpec>{});
|
std::vector<ParamSpec>{}, "NodeService");
|
||||||
|
|
||||||
// Update endpoint with file upload
|
// Update endpoint with file upload
|
||||||
api.addEndpoint("/api/node/update", HTTP_POST,
|
api.addEndpoint("/api/node/update", HTTP_POST,
|
||||||
@@ -18,72 +23,45 @@ void NodeService::registerEndpoints(ApiServer& api) {
|
|||||||
},
|
},
|
||||||
std::vector<ParamSpec>{
|
std::vector<ParamSpec>{
|
||||||
ParamSpec{String("firmware"), true, String("body"), String("file"), {}, String("")}
|
ParamSpec{String("firmware"), true, String("body"), String("file"), {}, String("")}
|
||||||
});
|
}, "NodeService");
|
||||||
|
|
||||||
// Restart endpoint
|
// Restart endpoint
|
||||||
api.addEndpoint("/api/node/restart", HTTP_POST,
|
api.addEndpoint("/api/node/restart", HTTP_POST,
|
||||||
[this](AsyncWebServerRequest* request) { handleRestartRequest(request); },
|
[this](AsyncWebServerRequest* request) { handleRestartRequest(request); },
|
||||||
std::vector<ParamSpec>{});
|
std::vector<ParamSpec>{}, "NodeService");
|
||||||
|
|
||||||
// Endpoints endpoint
|
// Endpoints endpoint
|
||||||
api.addEndpoint("/api/node/endpoints", HTTP_GET,
|
api.addEndpoint("/api/node/endpoints", HTTP_GET,
|
||||||
[this](AsyncWebServerRequest* request) { handleEndpointsRequest(request); },
|
[this](AsyncWebServerRequest* request) { handleEndpointsRequest(request); },
|
||||||
std::vector<ParamSpec>{});
|
std::vector<ParamSpec>{}, "NodeService");
|
||||||
|
|
||||||
// Generic local event endpoint
|
|
||||||
api.addEndpoint("/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::handleStatusRequest(AsyncWebServerRequest* request) {
|
void NodeService::handleStatusRequest(AsyncWebServerRequest* request) {
|
||||||
JsonDocument doc;
|
NodeStatusResponse response;
|
||||||
doc["freeHeap"] = ESP.getFreeHeap();
|
|
||||||
doc["chipId"] = ESP.getChipId();
|
|
||||||
doc["sdkVersion"] = ESP.getSdkVersion();
|
|
||||||
doc["cpuFreqMHz"] = ESP.getCpuFreqMHz();
|
|
||||||
doc["flashChipSize"] = ESP.getFlashChipSize();
|
|
||||||
|
|
||||||
// Include local node labels if present
|
// Get labels from member list or self
|
||||||
|
std::map<String, String> labels;
|
||||||
if (ctx.memberList) {
|
if (ctx.memberList) {
|
||||||
auto it = ctx.memberList->find(ctx.hostname);
|
auto it = ctx.memberList->find(ctx.hostname);
|
||||||
if (it != ctx.memberList->end()) {
|
if (it != ctx.memberList->end()) {
|
||||||
JsonObject labelsObj = doc["labels"].to<JsonObject>();
|
labels = it->second.labels;
|
||||||
for (const auto& kv : it->second.labels) {
|
|
||||||
labelsObj[kv.first.c_str()] = kv.second;
|
|
||||||
}
|
|
||||||
} else if (!ctx.self.labels.empty()) {
|
} else if (!ctx.self.labels.empty()) {
|
||||||
JsonObject labelsObj = doc["labels"].to<JsonObject>();
|
labels = ctx.self.labels;
|
||||||
for (const auto& kv : ctx.self.labels) {
|
|
||||||
labelsObj[kv.first.c_str()] = kv.second;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String json;
|
response.buildCompleteResponse(labels);
|
||||||
serializeJson(doc, json);
|
request->send(200, "application/json", response.toJsonString());
|
||||||
request->send(200, "application/json", json);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void NodeService::handleUpdateRequest(AsyncWebServerRequest* request) {
|
void NodeService::handleUpdateRequest(AsyncWebServerRequest* request) {
|
||||||
bool success = !Update.hasError();
|
bool success = !Update.hasError();
|
||||||
AsyncWebServerResponse* response = request->beginResponse(200, "application/json",
|
NodeOperationResponse response;
|
||||||
success ? "{\"status\": \"OK\"}" : "{\"status\": \"FAIL\"}");
|
response.setSuccess(success ? "OK" : "FAIL");
|
||||||
response->addHeader("Connection", "close");
|
|
||||||
request->send(response);
|
AsyncWebServerResponse* httpResponse = request->beginResponse(200, "application/json", response.toJsonString());
|
||||||
|
httpResponse->addHeader("Connection", "close");
|
||||||
|
request->send(httpResponse);
|
||||||
request->onDisconnect([this]() {
|
request->onDisconnect([this]() {
|
||||||
LOG_INFO("API", "Restart device");
|
LOG_INFO("API", "Restart device");
|
||||||
delay(10);
|
delay(10);
|
||||||
@@ -126,10 +104,12 @@ void NodeService::handleUpdateUpload(AsyncWebServerRequest* request, const Strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
void NodeService::handleRestartRequest(AsyncWebServerRequest* request) {
|
void NodeService::handleRestartRequest(AsyncWebServerRequest* request) {
|
||||||
AsyncWebServerResponse* response = request->beginResponse(200, "application/json",
|
NodeOperationResponse response;
|
||||||
"{\"status\": \"restarting\"}");
|
response.setSuccess("restarting");
|
||||||
response->addHeader("Connection", "close");
|
|
||||||
request->send(response);
|
AsyncWebServerResponse* httpResponse = request->beginResponse(200, "application/json", response.toJsonString());
|
||||||
|
httpResponse->addHeader("Connection", "close");
|
||||||
|
request->send(httpResponse);
|
||||||
request->onDisconnect([this]() {
|
request->onDisconnect([this]() {
|
||||||
LOG_INFO("API", "Restart device");
|
LOG_INFO("API", "Restart device");
|
||||||
delay(10);
|
delay(10);
|
||||||
@@ -138,36 +118,7 @@ void NodeService::handleRestartRequest(AsyncWebServerRequest* request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void NodeService::handleEndpointsRequest(AsyncWebServerRequest* request) {
|
void NodeService::handleEndpointsRequest(AsyncWebServerRequest* request) {
|
||||||
JsonDocument doc;
|
NodeEndpointsResponse response;
|
||||||
JsonArray endpointsArr = doc["endpoints"].to<JsonArray>();
|
response.addEndpoints(apiServer.getEndpoints());
|
||||||
|
request->send(200, "application/json", response.toJsonString());
|
||||||
// Add all registered endpoints from ApiServer
|
|
||||||
for (const auto& endpoint : apiServer.getEndpoints()) {
|
|
||||||
JsonObject obj = endpointsArr.add<JsonObject>();
|
|
||||||
obj["uri"] = endpoint.uri;
|
|
||||||
obj["method"] = ApiServer::methodToStr(endpoint.method);
|
|
||||||
if (!endpoint.params.empty()) {
|
|
||||||
JsonArray paramsArr = obj["params"].to<JsonArray>();
|
|
||||||
for (const auto& ps : endpoint.params) {
|
|
||||||
JsonObject p = paramsArr.add<JsonObject>();
|
|
||||||
p["name"] = ps.name;
|
|
||||||
p["location"] = ps.location;
|
|
||||||
p["required"] = ps.required;
|
|
||||||
p["type"] = ps.type;
|
|
||||||
if (!ps.values.empty()) {
|
|
||||||
JsonArray allowed = p["values"].to<JsonArray>();
|
|
||||||
for (const auto& v : ps.values) {
|
|
||||||
allowed.add(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (ps.defaultValue.length() > 0) {
|
|
||||||
p["default"] = ps.defaultValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String json;
|
|
||||||
serializeJson(doc, json);
|
|
||||||
request->send(200, "application/json", json);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
#include "spore/services/TaskService.h"
|
#include "spore/services/TaskService.h"
|
||||||
#include "spore/core/ApiServer.h"
|
#include "spore/core/ApiServer.h"
|
||||||
|
#include "spore/types/TaskResponse.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
|
||||||
|
using spore::types::TaskStatusResponse;
|
||||||
|
using spore::types::TaskControlResponse;
|
||||||
|
|
||||||
TaskService::TaskService(TaskManager& taskManager) : taskManager(taskManager) {}
|
TaskService::TaskService(TaskManager& taskManager) : taskManager(taskManager) {}
|
||||||
|
|
||||||
void TaskService::registerEndpoints(ApiServer& api) {
|
void TaskService::registerEndpoints(ApiServer& api) {
|
||||||
api.addEndpoint("/api/tasks/status", HTTP_GET,
|
api.addEndpoint("/api/tasks/status", HTTP_GET,
|
||||||
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
|
[this](AsyncWebServerRequest* request) { handleStatusRequest(request); },
|
||||||
std::vector<ParamSpec>{});
|
std::vector<ParamSpec>{}, "TaskService");
|
||||||
|
|
||||||
api.addEndpoint("/api/tasks/control", HTTP_POST,
|
api.addEndpoint("/api/tasks/control", HTTP_POST,
|
||||||
[this](AsyncWebServerRequest* request) { handleControlRequest(request); },
|
[this](AsyncWebServerRequest* request) { handleControlRequest(request); },
|
||||||
@@ -28,36 +32,19 @@ void TaskService::registerEndpoints(ApiServer& api) {
|
|||||||
{String("enable"), String("disable"), String("start"), String("stop"), String("status")},
|
{String("enable"), String("disable"), String("start"), String("stop"), String("status")},
|
||||||
String("")
|
String("")
|
||||||
}
|
}
|
||||||
});
|
}, "TaskService");
|
||||||
}
|
}
|
||||||
|
|
||||||
void TaskService::handleStatusRequest(AsyncWebServerRequest* request) {
|
void TaskService::handleStatusRequest(AsyncWebServerRequest* request) {
|
||||||
|
TaskStatusResponse response;
|
||||||
|
|
||||||
|
// Get task statuses using a separate document to avoid reference issues
|
||||||
JsonDocument scratch;
|
JsonDocument scratch;
|
||||||
auto taskStatuses = taskManager.getAllTaskStatuses(scratch);
|
auto taskStatuses = taskManager.getAllTaskStatuses(scratch);
|
||||||
|
|
||||||
JsonDocument doc;
|
// Build the complete response with the task data
|
||||||
JsonObject summaryObj = doc["summary"].to<JsonObject>();
|
response.buildCompleteResponse(taskStatuses);
|
||||||
summaryObj["totalTasks"] = taskStatuses.size();
|
request->send(200, "application/json", response.toJsonString());
|
||||||
summaryObj["activeTasks"] = std::count_if(taskStatuses.begin(), taskStatuses.end(),
|
|
||||||
[](const auto& pair) { return pair.second["enabled"]; });
|
|
||||||
|
|
||||||
JsonArray tasksArr = doc["tasks"].to<JsonArray>();
|
|
||||||
for (const auto& taskPair : taskStatuses) {
|
|
||||||
JsonObject taskObj = tasksArr.add<JsonObject>();
|
|
||||||
taskObj["name"] = taskPair.first;
|
|
||||||
taskObj["interval"] = taskPair.second["interval"];
|
|
||||||
taskObj["enabled"] = taskPair.second["enabled"];
|
|
||||||
taskObj["running"] = taskPair.second["running"];
|
|
||||||
taskObj["autoStart"] = taskPair.second["autoStart"];
|
|
||||||
}
|
|
||||||
|
|
||||||
JsonObject systemObj = doc["system"].to<JsonObject>();
|
|
||||||
systemObj["freeHeap"] = ESP.getFreeHeap();
|
|
||||||
systemObj["uptime"] = millis();
|
|
||||||
|
|
||||||
String json;
|
|
||||||
serializeJson(doc, json);
|
|
||||||
request->send(200, "application/json", json);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void TaskService::handleControlRequest(AsyncWebServerRequest* request) {
|
void TaskService::handleControlRequest(AsyncWebServerRequest* request) {
|
||||||
@@ -88,50 +75,27 @@ void TaskService::handleControlRequest(AsyncWebServerRequest* request) {
|
|||||||
success = true;
|
success = true;
|
||||||
message = "Task status retrieved";
|
message = "Task status retrieved";
|
||||||
|
|
||||||
JsonDocument statusDoc;
|
TaskControlResponse response;
|
||||||
statusDoc["success"] = success;
|
response.setResponse(success, message, taskName, action);
|
||||||
statusDoc["message"] = message;
|
response.addTaskDetails(taskName,
|
||||||
statusDoc["task"] = taskName;
|
taskManager.isTaskEnabled(taskName.c_str()),
|
||||||
statusDoc["action"] = action;
|
taskManager.isTaskRunning(taskName.c_str()),
|
||||||
|
taskManager.getTaskInterval(taskName.c_str()));
|
||||||
|
|
||||||
statusDoc["taskDetails"] = JsonObject();
|
request->send(200, "application/json", response.toJsonString());
|
||||||
JsonObject taskDetails = statusDoc["taskDetails"];
|
|
||||||
taskDetails["name"] = taskName;
|
|
||||||
taskDetails["enabled"] = taskManager.isTaskEnabled(taskName.c_str());
|
|
||||||
taskDetails["running"] = taskManager.isTaskRunning(taskName.c_str());
|
|
||||||
taskDetails["interval"] = taskManager.getTaskInterval(taskName.c_str());
|
|
||||||
|
|
||||||
taskDetails["system"] = JsonObject();
|
|
||||||
JsonObject systemInfo = taskDetails["system"];
|
|
||||||
systemInfo["freeHeap"] = ESP.getFreeHeap();
|
|
||||||
systemInfo["uptime"] = millis();
|
|
||||||
|
|
||||||
String statusJson;
|
|
||||||
serializeJson(statusDoc, statusJson);
|
|
||||||
request->send(200, "application/json", statusJson);
|
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
success = false;
|
success = false;
|
||||||
message = "Invalid action. Use: enable, disable, start, stop, or status";
|
message = "Invalid action. Use: enable, disable, start, stop, or status";
|
||||||
}
|
}
|
||||||
|
|
||||||
JsonDocument doc;
|
TaskControlResponse response;
|
||||||
doc["success"] = success;
|
response.setResponse(success, message, taskName, action);
|
||||||
doc["message"] = message;
|
request->send(success ? 200 : 400, "application/json", response.toJsonString());
|
||||||
doc["task"] = taskName;
|
|
||||||
doc["action"] = action;
|
|
||||||
|
|
||||||
String json;
|
|
||||||
serializeJson(doc, json);
|
|
||||||
request->send(success ? 200 : 400, "application/json", json);
|
|
||||||
} else {
|
} else {
|
||||||
JsonDocument doc;
|
TaskControlResponse response;
|
||||||
doc["success"] = false;
|
response.setError("Missing parameters. Required: task, action",
|
||||||
doc["message"] = "Missing parameters. Required: task, action";
|
"{\"task\": \"discovery_send\", \"action\": \"status\"}");
|
||||||
doc["example"] = "{\"task\": \"discovery_send\", \"action\": \"status\"}";
|
request->send(400, "application/json", response.toJsonString());
|
||||||
|
|
||||||
String json;
|
|
||||||
serializeJson(doc, json);
|
|
||||||
request->send(400, "application/json", json);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,10 @@ Config::Config() {
|
|||||||
api_server_port = 80;
|
api_server_port = 80;
|
||||||
|
|
||||||
// Cluster Configuration
|
// Cluster Configuration
|
||||||
discovery_interval_ms = 1000; // TODO retire this in favor of heartbeat_interval_ms
|
discovery_interval_ms = 1000;
|
||||||
cluster_listen_interval_ms = 10;
|
heartbeat_interval_ms = 2000;
|
||||||
heartbeat_interval_ms = 5000;
|
|
||||||
status_update_interval_ms = 1000;
|
status_update_interval_ms = 1000;
|
||||||
member_info_update_interval_ms = 10000; // TODO retire this in favor of heartbeat_interval_ms
|
member_info_update_interval_ms = 10000;
|
||||||
print_interval_ms = 5000;
|
print_interval_ms = 5000;
|
||||||
|
|
||||||
// Node Status Thresholds
|
// Node Status Thresholds
|
||||||
|
|||||||
1
test/.gitignore
vendored
1
test/.gitignore
vendored
@@ -1 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
69
test/README
69
test/README
@@ -1,64 +1,11 @@
|
|||||||
# Test Scripts
|
|
||||||
|
|
||||||
This directory contains JavaScript test scripts to interact with the Spore device, primarily for testing cluster event broadcasting.
|
This directory is intended for PlatformIO Test Runner and project tests.
|
||||||
|
|
||||||
## Prerequisites
|
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
|
||||||
These scripts require [Node.js](https://nodejs.org/) to be installed on your system.
|
control data, usage procedures, and operating procedures, are tested to
|
||||||
|
determine whether they are fit for use. Unit testing finds problems early
|
||||||
## How to Run
|
in the development cycle.
|
||||||
|
|
||||||
### 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
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
// 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);
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
// 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');
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
// 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');
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
// 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
33
test/package-lock.json
generated
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
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)`
|
|
||||||
);
|
|
||||||
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
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)`);
|
|
||||||
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
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})`
|
|
||||||
);
|
|
||||||
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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)`);
|
|
||||||
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
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})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
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})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
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)`
|
|
||||||
);
|
|
||||||
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
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)`,
|
|
||||||
);
|
|
||||||
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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)`);
|
|
||||||
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
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
1319
test/pixelstream/snek/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
(() => {
|
|
||||||
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);
|
|
||||||
})();
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
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 {}
|
|
||||||
});
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
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
855
test/pixelstream/tetris/package-lock.json
generated
@@ -1,855 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,563 +0,0 @@
|
|||||||
(() => {
|
|
||||||
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();
|
|
||||||
})();
|
|
||||||
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<!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>
|
|
||||||
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
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 {}
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
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})`
|
|
||||||
);
|
|
||||||
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
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